From fe6f9d8c364266ed57ea51c07ee69245399e0a9c Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Thu, 27 Aug 2026 12:20:40 -0600 Subject: [PATCH 01/13] fix(acp): wake agents from workflow messages Attribute relay-signed workflow output to its explicit owner only after verifying the event, canonical metadata, and the active NIP-11 relay key. Route that identity through the existing author and in-flight mode gates, including setup mode, and refresh it after relay reconnects. Keep the existing owner p tag so mentions-feed behavior is unchanged. Co-authored-by: LioLionel <62820906+LioLionel@users.noreply.github.com> Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- crates/buzz-acp/README.md | 4 + crates/buzz-acp/src/lib.rs | 279 ++++++++++++++++++++++++- crates/buzz-acp/src/relay.rs | 210 +++++++++++++++++++ crates/buzz-acp/src/setup_mode.rs | 8 +- crates/buzz-relay/src/workflow_sink.rs | 16 ++ 5 files changed, 509 insertions(+), 8 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..98778d565c4 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -147,6 +147,10 @@ Controls which authors' events the harness forwards to the agent. Events from di | `anyone` | Forward all events (no author filtering). | | `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. | +Relay-signed workflow messages with explicit owner provenance are first verified +against the relay's NIP-11 `self` key, then evaluated under the same author +policy as ordinary messages. `nobody` remains absolute. + The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode: | Command | Effect | diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 25c6e549052..4b9a7c2dcf8 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -275,6 +275,85 @@ async fn author_allowed( } } +/// Return the workflow owner attributed by a relay-signed workflow message. +/// +/// `buzz:workflow-owner` alone is not authority: any ordinary event author can +/// forge custom tags. Attribution is accepted only for a cryptographically +/// valid kind:9 event signed by the active relay's NIP-11 `self` key, with +/// exactly one canonical workflow marker and one canonical owner pubkey. +/// Mention `p` tags are deliberately ignored as author-gate authority. +fn verified_workflow_owner(event: &nostr::Event, relay_self: Option<&str>) -> Option { + if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE { + return None; + } + + let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?; + if event.pubkey != relay_self || event.verify().is_err() { + return None; + } + + let markers: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow")) + .collect(); + if markers.as_slice() != [["buzz:workflow", "true"]] { + return None; + } + + let owners: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow-owner")) + .collect(); + let [owner_tag] = owners.as_slice() else { + return None; + }; + let [_, owner_value] = owner_tag else { + return None; + }; + + nostr::PublicKey::from_hex(owner_value) + .ok() + .map(|owner| owner.to_hex()) +} + +/// Resolve the author principal used by the inbound author gate. +fn effective_prompt_author(event: &nostr::Event, relay_self: Option<&str>) -> String { + verified_workflow_owner(event, relay_self).unwrap_or_else(|| event.pubkey.to_hex()) +} + +/// Refresh the relay signing identity, logging why delegated workflow +/// attribution is unavailable. A transient fetch error keeps the last verified +/// key so a reconnect blip cannot disable workflow wakes until another reconnect. +async fn refresh_relay_self( + rest_client: &relay::RestClient, + current: Option, + context: &str, +) -> Option { + match rest_client.relay_self().await { + Ok(Some(pubkey)) => Some(pubkey), + Ok(None) => { + tracing::warn!( + %context, + "relay NIP-11 document has no `self` key — workflow attribution remains fail-closed" + ); + None + } + Err(error) => { + tracing::warn!( + %context, + %error, + retaining_previous_identity = current.is_some(), + "failed to refresh relay NIP-11 identity" + ); + current + } + } +} + /// Resolve whether `channel_id` is a DM, for the inbound author gate. /// /// Resolution order: @@ -2019,6 +2098,9 @@ async fn tokio_main() -> Result<()> { tracing::info!("connected to relay at {}", config.relay_url); + let relay_rest_client = relay.rest_client(); + let mut relay_self = refresh_relay_self(&relay_rest_client, None, "startup").await; + relay .subscribe_membership_notifications() .await @@ -2867,8 +2949,11 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. + let author_hex = effective_prompt_author( + &buzz_event.event, + relay_self.as_deref(), + ); { - let author = buzz_event.event.pubkey.to_hex(); // DM hardening: resolve channel type (fail-closed // to DM) so allowlist/anyone modes cannot be // exercised by non-owner authors inside DMs. @@ -2877,7 +2962,7 @@ async fn tokio_main() -> Result<()> { let allowed = author_allowed( &config.respond_to, &config.respond_to_allowlist, - &author, + &author_hex, is_dm, &owner_cache, &ctx.rest_client, @@ -2886,7 +2971,8 @@ async fn tokio_main() -> Result<()> { if !allowed { tracing::debug!( channel_id = %buzz_event.channel_id, - author = %buzz_event.event.pubkey.to_hex(), + raw_author = %buzz_event.event.pubkey.to_hex(), + effective_author = %author_hex, mode = %config.respond_to, is_dm, "inbound author gate — dropping event" @@ -2903,9 +2989,9 @@ async fn tokio_main() -> Result<()> { continue; } }; - // Capture author pubkey before queue.push() moves - // buzz_event.event (needed for mode gate below). - let author_hex = buzz_event.event.pubkey.to_hex(); + // The effective author was captured before queue.push() + // moved the event; the mode gate uses the same verified + // principal as the inbound author gate. let event_id_hex = buzz_event.event.id.to_hex(); // Clone for the non-cancelling steer fork, which // needs the event to render the steer body. The @@ -2996,6 +3082,12 @@ async fn tokio_main() -> Result<()> { tokio::time::sleep(Duration::from_secs(1)).await; break; } + relay_self = refresh_relay_self( + &relay_rest_client, + relay_self, + "reconnect", + ) + .await; } } None @@ -5366,6 +5458,149 @@ mod owner_cache_tests { } } +#[cfg(test)] +mod workflow_owner_tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + fn workflow_event( + signer: &Keys, + owner: Option<&str>, + marker_tags: &[&[&str]], + recipient: Option<&str>, + ) -> nostr::Event { + let mut tags = Vec::new(); + for marker in marker_tags { + tags.push(Tag::parse(marker.iter().copied()).expect("workflow marker")); + } + if let Some(owner) = owner { + tags.push(Tag::parse(["buzz:workflow-owner", owner]).expect("workflow owner tag")); + } + if let Some(recipient) = recipient { + tags.push(Tag::parse(["p", recipient]).expect("p tag")); + } + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags(tags) + .sign_with_keys(signer) + .expect("signed event") + } + + #[tokio::test] + async fn relay_identity_refresh_keeps_last_good_key_after_fetch_error() { + let previous = Keys::generate().public_key().to_hex(); + let client = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:0".into(), + keys: Keys::generate(), + auth_tag_json: None, + }; + + assert_eq!( + refresh_relay_self(&client, Some(previous.clone()), "test").await, + Some(previous) + ); + } + + #[test] + fn trusted_relay_workflow_uses_owner_not_recipient() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let recipient = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + Some(&recipient), + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex())), + owner + ); + } + + #[test] + fn forged_or_tampered_workflow_keeps_raw_signer() { + let relay = Keys::generate(); + let attacker = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let forged = workflow_event(&attacker, Some(&owner), &[&["buzz:workflow", "true"]], None); + assert_eq!( + effective_prompt_author(&forged, Some(&relay.public_key().to_hex())), + attacker.public_key().to_hex() + ); + + let mut tampered = + workflow_event(&relay, Some(&owner), &[&["buzz:workflow", "true"]], None); + tampered.content = "tampered".into(); + assert_eq!( + effective_prompt_author(&tampered, Some(&relay.public_key().to_hex())), + relay.public_key().to_hex() + ); + } + + #[test] + fn malformed_or_ambiguous_metadata_fails_closed() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + + for event in [ + workflow_event(&relay, Some(&owner), &[], None), + workflow_event(&relay, None, &[&["buzz:workflow", "true"]], None), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"], &["buzz:workflow", "true"]], + None, + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true", "extra"]], + None, + ), + ] { + assert_eq!(effective_prompt_author(&event, Some(&relay_hex)), relay_hex); + } + + let duplicate_owner = + EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") + .tags([ + Tag::parse(["buzz:workflow", "true"]).expect("marker"), + Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("owner"), + Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("duplicate owner"), + ]) + .sign_with_keys(&relay) + .expect("signed event"); + assert_eq!( + effective_prompt_author(&duplicate_owner, Some(&relay_hex)), + relay_hex + ); + } + + #[test] + fn wrong_kind_or_missing_relay_identity_fails_closed() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let relay_hex = relay.public_key().to_hex(); + let wrong_kind = EventBuilder::new(Kind::TextNote, "scheduled prompt") + .tags([ + Tag::parse(["buzz:workflow", "true"]).expect("marker"), + Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("owner"), + ]) + .sign_with_keys(&relay) + .expect("signed event"); + assert_eq!( + effective_prompt_author(&wrong_kind, Some(&relay_hex)), + relay_hex + ); + + let valid = workflow_event(&relay, Some(&owner), &[&["buzz:workflow", "true"]], None); + assert_eq!(effective_prompt_author(&valid, None), relay_hex); + } +} + #[cfg(test)] mod author_gate_tests { use super::*; @@ -5396,6 +5631,38 @@ mod author_gate_tests { cache } + #[tokio::test] + async fn test_owner_only_accepts_trusted_workflow_owner() { + let relay = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["p", STRANGER]).expect("recipient tag"), + ]) + .sign_with_keys(&relay) + .expect("signed workflow event"); + let effective_author = effective_prompt_author(&event, Some(&relay.public_key().to_hex())); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); + + assert!( + author_allowed( + &RespondTo::OwnerOnly, + &HashSet::new(), + &effective_author, + false, + &cache, + &dummy_rest_client(), + ) + .await, + "a verified workflow owner must flow through the existing sibling policy" + ); + } + #[tokio::test] async fn test_allowlist_accepts_sibling_not_in_allowlist() { let cache = cache_with_sibling(); diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 6188e57a11d..5f1aa8dff3c 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -277,6 +277,75 @@ fn unix_now_secs() -> u64 { } impl RestClient { + /// Fetch the relay's stable signing identity from its NIP-11 document. + /// + /// Relay-authored workflow attribution is trusted only when the event signer + /// matches this key. Missing, malformed, or unavailable identity data fails + /// closed by returning an error/`None` to the caller. NIP-11 is standardized + /// at the relay root; `/info` remains a compatibility fallback for relays + /// that expose the document through Buzz's explicit alias. + pub async fn relay_self(&self) -> Result, RelayError> { + let mut failures = Vec::new(); + let mut saw_document_without_self = false; + + for path in ["/", "/info"] { + let url = format!("{}{path}", self.base_url); + let response = match self + .http + .get(&url) + .header(reqwest::header::ACCEPT, "application/nostr+json") + .send() + .await + { + Ok(response) => response, + Err(error) => { + failures.push(format!("GET {path} failed: {error}")); + continue; + } + }; + + if !response.status().is_success() { + failures.push(format!("GET {path} returned HTTP {}", response.status())); + continue; + } + + let document: serde_json::Value = match response.json().await { + Ok(document) => document, + Err(error) => { + failures.push(format!("GET {path} returned invalid NIP-11 JSON: {error}")); + continue; + } + }; + let Some(relay_self) = document.get("self") else { + saw_document_without_self = true; + continue; + }; + let Some(relay_self) = relay_self.as_str() else { + failures.push(format!("GET {path} returned a non-string NIP-11 self key")); + continue; + }; + let relay_self = match nostr::PublicKey::from_hex(relay_self) { + Ok(pubkey) => pubkey.to_hex(), + Err(error) => { + failures.push(format!( + "GET {path} returned an invalid NIP-11 self key: {error}" + )); + continue; + } + }; + return Ok(Some(relay_self)); + } + + if saw_document_without_self { + Ok(None) + } else { + Err(RelayError::Http(format!( + "failed to fetch a usable NIP-11 document: {}", + failures.join("; ") + ))) + } + } + /// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body. /// /// Returns the `Authorization: Nostr ` header value (without the @@ -4084,6 +4153,147 @@ async fn wait_for_any_ok( mod tests { use super::*; + async fn nip11_test_client( + responses: HashMap, + ) -> ( + RestClient, + std::sync::Arc>>, + tokio::task::JoinHandle<()>, + ) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind NIP-11 test server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("test server address") + ); + let requests = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let server_requests = requests.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut request = vec![0; 8192]; + let bytes_read = socket.read(&mut request).await.unwrap_or_default(); + let request = String::from_utf8_lossy(&request[..bytes_read]); + let path = request + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or("/") + .to_string(); + let has_nip11_accept = request + .lines() + .any(|line| line.eq_ignore_ascii_case("accept: application/nostr+json")); + server_requests + .lock() + .expect("lock recorded NIP-11 requests") + .push((path.clone(), has_nip11_accept)); + + let (status, body) = responses + .get(&path) + .cloned() + .unwrap_or_else(|| (404, "not found".into())); + let reason = if status == 200 { "OK" } else { "Not Found" }; + let response = format!( + "HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let client = RestClient { + http: reqwest::Client::new(), + base_url, + keys: Keys::generate(), + auth_tag_json: None, + }; + (client, requests, server) + } + + #[tokio::test] + async fn relay_self_reads_and_normalizes_standard_root_document() { + let uppercase = "AB".repeat(32); + let responses = HashMap::from([ + ( + "/".to_string(), + (200, serde_json::json!({ "self": uppercase }).to_string()), + ), + ( + "/info".to_string(), + ( + 200, + serde_json::json!({ "self": "cd".repeat(32) }).to_string(), + ), + ), + ]); + let (client, requests, server) = nip11_test_client(responses).await; + + assert_eq!( + client.relay_self().await.expect("fetch relay self"), + Some("ab".repeat(32)) + ); + assert_eq!( + *requests.lock().expect("lock recorded requests"), + vec![("/".to_string(), true)], + "the standard root document should be preferred and request NIP-11 JSON" + ); + server.abort(); + } + + #[tokio::test] + async fn relay_self_falls_back_to_info_alias() { + let responses = HashMap::from([ + ("/".to_string(), (404, "not found".into())), + ( + "/info".to_string(), + ( + 200, + serde_json::json!({ "self": "cd".repeat(32) }).to_string(), + ), + ), + ]); + let (client, requests, server) = nip11_test_client(responses).await; + + assert_eq!( + client.relay_self().await.expect("fetch relay self"), + Some("cd".repeat(32)) + ); + assert_eq!( + *requests.lock().expect("lock recorded requests"), + vec![("/".to_string(), true), ("/info".to_string(), true)] + ); + server.abort(); + } + + #[tokio::test] + async fn relay_self_rejects_malformed_identity_at_both_endpoints() { + let responses = HashMap::from([ + ( + "/".to_string(), + ( + 200, + serde_json::json!({ "self": "not-a-pubkey" }).to_string(), + ), + ), + ( + "/info".to_string(), + (200, serde_json::json!({ "self": 42 }).to_string()), + ), + ]); + let (client, _requests, server) = nip11_test_client(responses).await; + + let error = client + .relay_self() + .await + .expect_err("malformed relay identities must fail closed"); + assert!(error + .to_string() + .contains("failed to fetch a usable NIP-11 document")); + server.abort(); + } + #[test] fn relay_ws_to_http_plain() { assert_eq!( diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index b1a9372ea46..7311185c932 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -342,6 +342,9 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::info!("setup-mode: connected and subscribed to membership notifications"); + let rest_client = relay.rest_client(); + let mut relay_self = crate::refresh_relay_self(&rest_client, None, "setup startup").await; + // Resolve owner for author-gate (same priority as normal mode). let startup_owner = crate::resolve_agent_owner(&config); let owner_cache = crate::OwnerCache::new(startup_owner); @@ -381,7 +384,6 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> } let publisher = relay.event_publisher(); - let rest_client = relay.rest_client(); let channel_info = crate::pool::ChannelInfoResolver::new(channel_info_map, rest_client.clone()); @@ -395,6 +397,8 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::error!("setup-mode: relay background task is gone: {e} — exiting"); break; } + relay_self = + crate::refresh_relay_self(&rest_client, relay_self, "setup reconnect").await; continue; }; @@ -428,7 +432,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let author_hex = buzz_event.event.pubkey.to_hex(); + let author_hex = crate::effective_prompt_author(&buzz_event.event, relay_self.as_deref()); let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; let allowed = author_allowed( &config.respond_to, diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 8ce23a2e8ea..34d92d2fdd0 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -257,6 +257,8 @@ impl ActionSink for RelayActionSink { // - `p` tag attributes the message to the workflow owner // - `h` tag scopes to the channel (NIP-29, canonical UUID) // - `buzz:workflow` tag prevents recursive workflow triggering + // - `buzz:workflow-owner` lets harnesses apply the owner's + // inbound-author policy after verifying the relay signature // - one `p` tag per `@Name` that resolves to a channel member, // so mentioned agents are woken (wake is `p`-tag gated) let mut tags = vec![ @@ -266,6 +268,8 @@ impl ActionSink for RelayActionSink { .map_err(|e| ActionSinkError::EventBuild(format!("h tag: {e}")))?, Tag::parse(["buzz:workflow", "true"]) .map_err(|e| ActionSinkError::EventBuild(format!("workflow tag: {e}")))?, + Tag::parse(["buzz:workflow-owner", &author_pubkey_hex]) + .map_err(|e| ActionSinkError::EventBuild(format!("workflow owner tag: {e}")))?, ]; // Resolve thread ancestry when this is a threaded reply, so the @@ -775,6 +779,18 @@ mod integration_tests { p_tag_targets.contains(&agent_hex.as_str()), "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); + + let owner_tag = stored + .event + .tags + .iter() + .find(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz:workflow-owner")) + .and_then(|tag| tag.as_slice().get(1).map(String::as_str)); + assert_eq!( + owner_tag, + Some(author_hex.as_str()), + "workflow owner must be explicit so consumers never infer it from p-tag order" + ); } #[tokio::test] From fe5b55619fe44176343eefb4cb7fe180df45a7d8 Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Thu, 27 Aug 2026 15:22:52 -0600 Subject: [PATCH 02/13] fix(workflows): bind agent wake to authored mentions Require relay-authenticated workflow mention provenance for the receiving agent and derive that authority only from the stored, unrendered workflow step template. Rendered trigger data keeps legacy mention routing but cannot borrow the workflow owner identity. Exercise ACP and workflow provenance guards in CI so the trust boundary cannot silently regress. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- .github/workflows/ci.yml | 12 + Justfile | 4 + crates/buzz-acp/README.md | 11 +- crates/buzz-acp/src/lib.rs | 475 ++++++++++++++++++++---- crates/buzz-acp/src/setup_mode.rs | 11 +- crates/buzz-relay/src/workflow_sink.rs | 378 ++++++++++++++++--- crates/buzz-workflow/src/action_sink.rs | 6 +- crates/buzz-workflow/src/executor.rs | 16 +- scripts/run-tests.sh | 5 + 9 files changed, 773 insertions(+), 145 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81b439a73d1..a5e9e8c687c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -684,6 +684,18 @@ jobs: VALUES ('00000000-0000-4000-8000-00000000c0de', 'localhost:3000') ON CONFLICT (lower(host)) DO NOTHING ;" + - name: Workflow message provenance tests + # The relay's workflow_sink suite is not selected by the infra-free + # unit job. Run both its pure tests and ignored PostgreSQL tests here so + # authored-template provenance cannot regress behind a green CI build. + run: | + cargo nextest run \ + --archive-file target/ci/backend-integration-tests.tar.zst \ + -E 'package(buzz-relay) and test(/workflow_sink/)' \ + --run-ignored all + env: + DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz + TEST_DATABASE_URL: postgres://buzz:${{ env.BUZZ_TEST_POSTGRES_PASSWORD }}@localhost:5432/buzz - name: Replaceable persistence PostgreSQL tests # Transaction, concurrency, and mention-index coverage for the # replaceable-event store seam. These tests require real Postgres and diff --git a/Justfile b/Justfile index 3cd03874538..eca431bce5e 100644 --- a/Justfile +++ b/Justfile @@ -347,6 +347,10 @@ test-unit: # `cargo test --workspace`; without this step a manifest edit that # diverges Rust from the corpus ships green. cargo nextest run -p buzz-agent --lib + # ACP author-gate and queue tests protect the trust boundary between + # relay events and agent prompts. They are infra-free; ignored lifecycle + # tests remain excluded and run in their dedicated integration lanes. + cargo nextest run -p buzz-acp --lib else ./scripts/run-tests.sh unit fi diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 98778d565c4..d385e8848b4 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -147,9 +147,14 @@ Controls which authors' events the harness forwards to the agent. Events from di | `anyone` | Forward all events (no author filtering). | | `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. | -Relay-signed workflow messages with explicit owner provenance are first verified -against the relay's NIP-11 `self` key, then evaluated under the same author -policy as ordinary messages. `nobody` remains absolute. +Relay-signed workflow messages delegate to their recorded owner only when they +explicitly target this agent with authenticated workflow-mention provenance. +The owner tag means that owner scheduled the workflow; it does not claim that +the owner authored every word after template rendering. ACP verifies the +provenance against the relay's NIP-11 `self` key, then evaluates the owner under +the same author policy as ordinary messages. Legacy workflow messages and +workflow output without an explicit agent mention remain attributed to the relay +signer. `nobody` remains absolute. The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode: diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 4b9a7c2dcf8..566828c4cc3 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -280,9 +280,15 @@ async fn author_allowed( /// `buzz:workflow-owner` alone is not authority: any ordinary event author can /// forge custom tags. Attribution is accepted only for a cryptographically /// valid kind:9 event signed by the active relay's NIP-11 `self` key, with -/// exactly one canonical workflow marker and one canonical owner pubkey. -/// Mention `p` tags are deliberately ignored as author-gate authority. -fn verified_workflow_owner(event: &nostr::Event, relay_self: Option<&str>) -> Option { +/// exactly one canonical workflow marker and owner pubkey. The current agent +/// must also have exactly one canonical `buzz:workflow-mention` tag; legacy `p` +/// tags are deliberately ignored as author-gate authority because workflows +/// retain an owner `p` tag for mentions-feed compatibility. +fn verified_workflow_owner( + event: &nostr::Event, + relay_self: Option<&str>, + agent_pubkey_hex: &str, +) -> Option { if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE { return None; } @@ -314,20 +320,96 @@ fn verified_workflow_owner(event: &nostr::Event, relay_self: Option<&str>) -> Op let [_, owner_value] = owner_tag else { return None; }; + let owner = nostr::PublicKey::from_hex(owner_value).ok()?.to_hex(); + if owner_value.as_str() != owner { + return None; + } - nostr::PublicKey::from_hex(owner_value) - .ok() - .map(|owner| owner.to_hex()) + let agent_pubkey = nostr::PublicKey::from_hex(agent_pubkey_hex).ok()?.to_hex(); + let workflow_mentions: Vec<&[String]> = event + .tags + .iter() + .map(|tag| tag.as_slice()) + .filter(|values| values.first().map(String::as_str) == Some("buzz:workflow-mention")) + .collect(); + let mut mentioned_pubkeys = HashSet::with_capacity(workflow_mentions.len()); + for mention_tag in workflow_mentions { + let [_, mention_value] = mention_tag else { + return None; + }; + let mention = nostr::PublicKey::from_hex(mention_value).ok()?.to_hex(); + if mention_value.as_str() != mention || !mentioned_pubkeys.insert(mention) { + return None; + } + } + if !mentioned_pubkeys.contains(&agent_pubkey) { + return None; + } + + Some(owner) } /// Resolve the author principal used by the inbound author gate. -fn effective_prompt_author(event: &nostr::Event, relay_self: Option<&str>) -> String { - verified_workflow_owner(event, relay_self).unwrap_or_else(|| event.pubkey.to_hex()) +fn effective_prompt_author( + event: &nostr::Event, + relay_self: Option<&str>, + agent_pubkey_hex: &str, +) -> String { + verified_workflow_owner(event, relay_self, agent_pubkey_hex) + .unwrap_or_else(|| event.pubkey.to_hex()) +} + +/// Combined event-to-author gate used by both the normal and setup listeners. +/// +/// Keeping workflow attribution and the existing author policy in one helper +/// prevents either runtime path from accidentally reverting to the raw relay +/// signer while unit tests continue to exercise only the individual pieces. +struct InboundAuthorGateDecision { + effective_author: String, + allowed: bool, +} + +struct WorkflowAuthorContext<'a> { + relay_self: Option<&'a str>, + agent_pubkey_hex: &'a str, +} + +async fn evaluate_inbound_author_gate( + event: &nostr::Event, + workflow_author: WorkflowAuthorContext<'_>, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, +) -> InboundAuthorGateDecision { + let effective_author = effective_prompt_author( + event, + workflow_author.relay_self, + workflow_author.agent_pubkey_hex, + ); + let allowed = author_allowed( + respond_to, + allowlist, + &effective_author, + is_dm, + owner_cache, + rest_client, + ) + .await; + InboundAuthorGateDecision { + effective_author, + allowed, + } } /// Refresh the relay signing identity, logging why delegated workflow /// attribution is unavailable. A transient fetch error keeps the last verified -/// key so a reconnect blip cannot disable workflow wakes until another reconnect. +/// key so a reconnect blip cannot disable workflow wakes. That availability +/// tradeoff creates a bounded-by-success revocation window: a rotated-away key +/// remains trusted while NIP-11 refreshes keep failing, then is replaced or +/// cleared by the next successful response. Refresh currently runs at startup +/// and reconnect, so rotation is not observed until a reconnect. async fn refresh_relay_self( rest_client: &relay::RestClient, current: Option, @@ -2949,36 +3031,32 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. - let author_hex = effective_prompt_author( + let is_dm = + is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; + let author_gate = evaluate_inbound_author_gate( &buzz_event.event, - relay_self.as_deref(), - ); - { - // DM hardening: resolve channel type (fail-closed - // to DM) so allowlist/anyone modes cannot be - // exercised by non-owner authors inside DMs. - let is_dm = - is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; - let allowed = author_allowed( - &config.respond_to, - &config.respond_to_allowlist, - &author_hex, + WorkflowAuthorContext { + relay_self: relay_self.as_deref(), + agent_pubkey_hex: &pubkey_hex, + }, + &config.respond_to, + &config.respond_to_allowlist, + is_dm, + &owner_cache, + &ctx.rest_client, + ) + .await; + let author_hex = author_gate.effective_author; + if !author_gate.allowed { + tracing::debug!( + channel_id = %buzz_event.channel_id, + raw_author = %buzz_event.event.pubkey.to_hex(), + effective_author = %author_hex, + mode = %config.respond_to, is_dm, - &owner_cache, - &ctx.rest_client, - ) - .await; - if !allowed { - tracing::debug!( - channel_id = %buzz_event.channel_id, - raw_author = %buzz_event.event.pubkey.to_hex(), - effective_author = %author_hex, - mode = %config.respond_to, - is_dm, - "inbound author gate — dropping event" - ); - continue; - } + "inbound author gate — dropping event" + ); + continue; } let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; @@ -5467,7 +5545,8 @@ mod workflow_owner_tests { signer: &Keys, owner: Option<&str>, marker_tags: &[&[&str]], - recipient: Option<&str>, + workflow_mentions: &[&[&str]], + p_tags: &[&str], ) -> nostr::Event { let mut tags = Vec::new(); for marker in marker_tags { @@ -5476,8 +5555,11 @@ mod workflow_owner_tests { if let Some(owner) = owner { tags.push(Tag::parse(["buzz:workflow-owner", owner]).expect("workflow owner tag")); } - if let Some(recipient) = recipient { - tags.push(Tag::parse(["p", recipient]).expect("p tag")); + for mention in workflow_mentions { + tags.push(Tag::parse(mention.iter().copied()).expect("workflow mention tag")); + } + for recipient in p_tags { + tags.push(Tag::parse(["p", *recipient]).expect("p tag")); } EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") .tags(tags) @@ -5502,39 +5584,135 @@ mod workflow_owner_tests { } #[test] - fn trusted_relay_workflow_uses_owner_not_recipient() { + fn trusted_relay_workflow_uses_owner_for_explicit_target() { let relay = Keys::generate(); let owner = Keys::generate().public_key().to_hex(); - let recipient = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); let event = workflow_event( &relay, Some(&owner), &[&["buzz:workflow", "true"]], - Some(&recipient), + &[&["buzz:workflow-mention", agent.as_str()]], + &[owner.as_str(), agent.as_str()], ); assert_eq!( - effective_prompt_author(&event, Some(&relay.public_key().to_hex())), + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), owner ); } + #[test] + fn multiple_explicit_targets_each_use_owner() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent_a = Keys::generate().public_key().to_hex(); + let agent_b = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[ + &["buzz:workflow-mention", agent_a.as_str()], + &["buzz:workflow-mention", agent_b.as_str()], + ], + &[owner.as_str(), agent_a.as_str(), agent_b.as_str()], + ); + + for agent in [&agent_a, &agent_b] { + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), agent), + owner + ); + } + } + + #[test] + fn owner_as_explicit_target_uses_owner_without_duplicate_p_tag() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", owner.as_str()]], + &[owner.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &owner), + owner + ); + } + + #[test] + fn legacy_owner_p_tag_without_explicit_target_keeps_relay_signer() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = owner.clone(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[], + &[owner.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + + #[test] + fn p_tag_without_matching_explicit_target_keeps_relay_signer() { + let relay = Keys::generate(); + let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); + let other = Keys::generate().public_key().to_hex(); + let event = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", other.as_str()]], + &[owner.as_str(), agent.as_str(), other.as_str()], + ); + + assert_eq!( + effective_prompt_author(&event, Some(&relay.public_key().to_hex()), &agent), + relay.public_key().to_hex() + ); + } + #[test] fn forged_or_tampered_workflow_keeps_raw_signer() { let relay = Keys::generate(); let attacker = Keys::generate(); let owner = Keys::generate().public_key().to_hex(); - let forged = workflow_event(&attacker, Some(&owner), &[&["buzz:workflow", "true"]], None); + let agent = Keys::generate().public_key().to_hex(); + let mentions = [&["buzz:workflow-mention", agent.as_str()][..]]; + let forged = workflow_event( + &attacker, + Some(&owner), + &[&["buzz:workflow", "true"]], + &mentions, + &[agent.as_str()], + ); assert_eq!( - effective_prompt_author(&forged, Some(&relay.public_key().to_hex())), + effective_prompt_author(&forged, Some(&relay.public_key().to_hex()), &agent), attacker.public_key().to_hex() ); - let mut tampered = - workflow_event(&relay, Some(&owner), &[&["buzz:workflow", "true"]], None); + let mut tampered = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &mentions, + &[agent.as_str()], + ); tampered.content = "tampered".into(); assert_eq!( - effective_prompt_author(&tampered, Some(&relay.public_key().to_hex())), + effective_prompt_author(&tampered, Some(&relay.public_key().to_hex()), &agent), relay.public_key().to_hex() ); } @@ -5543,38 +5721,86 @@ mod workflow_owner_tests { fn malformed_or_ambiguous_metadata_fails_closed() { let relay = Keys::generate(); let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); let relay_hex = relay.public_key().to_hex(); + let valid_mentions = [&["buzz:workflow-mention", agent.as_str()][..]]; for event in [ - workflow_event(&relay, Some(&owner), &[], None), - workflow_event(&relay, None, &[&["buzz:workflow", "true"]], None), workflow_event( &relay, Some(&owner), - &[&["buzz:workflow", "true"], &["buzz:workflow", "true"]], + &[], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, None, + &[&["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"], &["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], ), workflow_event( &relay, Some(&owner), &[&["buzz:workflow", "true", "extra"]], - None, + &valid_mentions, + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str(), "extra"]], + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[ + &["buzz:workflow-mention", agent.as_str()], + &["buzz:workflow-mention", agent.as_str()], + ], + &[agent.as_str()], + ), + workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", "not-a-pubkey"]], + &[agent.as_str()], ), ] { - assert_eq!(effective_prompt_author(&event, Some(&relay_hex)), relay_hex); + assert_eq!( + effective_prompt_author(&event, Some(&relay_hex), &agent), + relay_hex + ); } + let duplicate_owner = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &valid_mentions, + &[agent.as_str()], + ); + let mut tags: Vec = duplicate_owner.tags.iter().cloned().collect(); + tags.push(Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("duplicate owner")); let duplicate_owner = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt") - .tags([ - Tag::parse(["buzz:workflow", "true"]).expect("marker"), - Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("owner"), - Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("duplicate owner"), - ]) + .tags(tags) .sign_with_keys(&relay) .expect("signed event"); assert_eq!( - effective_prompt_author(&duplicate_owner, Some(&relay_hex)), + effective_prompt_author(&duplicate_owner, Some(&relay_hex), &agent), relay_hex ); } @@ -5583,21 +5809,29 @@ mod workflow_owner_tests { fn wrong_kind_or_missing_relay_identity_fails_closed() { let relay = Keys::generate(); let owner = Keys::generate().public_key().to_hex(); + let agent = Keys::generate().public_key().to_hex(); let relay_hex = relay.public_key().to_hex(); let wrong_kind = EventBuilder::new(Kind::TextNote, "scheduled prompt") .tags([ Tag::parse(["buzz:workflow", "true"]).expect("marker"), Tag::parse(["buzz:workflow-owner", owner.as_str()]).expect("owner"), + Tag::parse(["buzz:workflow-mention", agent.as_str()]).expect("workflow mention"), ]) .sign_with_keys(&relay) .expect("signed event"); assert_eq!( - effective_prompt_author(&wrong_kind, Some(&relay_hex)), + effective_prompt_author(&wrong_kind, Some(&relay_hex), &agent), relay_hex ); - let valid = workflow_event(&relay, Some(&owner), &[&["buzz:workflow", "true"]], None); - assert_eq!(effective_prompt_author(&valid, None), relay_hex); + let valid = workflow_event( + &relay, + Some(&owner), + &[&["buzz:workflow", "true"]], + &[&["buzz:workflow-mention", agent.as_str()]], + &[agent.as_str()], + ); + assert_eq!(effective_prompt_author(&valid, None, &agent), relay_hex); } } @@ -5632,34 +5866,123 @@ mod author_gate_tests { } #[tokio::test] - async fn test_owner_only_accepts_trusted_workflow_owner() { + async fn test_combined_gate_accepts_explicit_trusted_workflow_target_only() { let relay = nostr::Keys::generate(); let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); let event = nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") .tags([ nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) .expect("workflow owner tag"), - nostr::Tag::parse(["p", STRANGER]).expect("recipient tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent.as_str()]) + .expect("workflow mention tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("recipient tag"), ]) .sign_with_keys(&relay) .expect("signed workflow event"); - let effective_author = effective_prompt_author(&event, Some(&relay.public_key().to_hex())); let cache = cache_with_sibling(); cache.cache_sibling(workflow_owner.clone(), true); + let decision = evaluate_inbound_author_gate( + &event, + WorkflowAuthorContext { + relay_self: Some(&relay.public_key().to_hex()), + agent_pubkey_hex: &agent, + }, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &dummy_rest_client(), + ) + .await; + assert_eq!(decision.effective_author, workflow_owner); assert!( - author_allowed( - &RespondTo::OwnerOnly, - &HashSet::new(), - &effective_author, - false, - &cache, - &dummy_rest_client(), - ) - .await, - "a verified workflow owner must flow through the existing sibling policy" + decision.allowed, + "a verified workflow owner for an explicitly targeted agent must flow through the existing sibling policy" + ); + } + + #[tokio::test] + async fn test_combined_gate_rejects_owner_p_tag_without_explicit_workflow_target() { + let relay = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = workflow_owner.clone(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("legacy owner p tag"), + ]) + .sign_with_keys(&relay) + .expect("signed workflow event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(relay.public_key().to_hex(), false); + + let decision = evaluate_inbound_author_gate( + &event, + WorkflowAuthorContext { + relay_self: Some(&relay.public_key().to_hex()), + agent_pubkey_hex: &agent, + }, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &dummy_rest_client(), + ) + .await; + assert_eq!(decision.effective_author, relay.public_key().to_hex()); + assert!( + !decision.allowed, + "the legacy owner p tag alone must not wake an agent-owned workflow" + ); + } + + #[tokio::test] + async fn test_combined_gate_rejects_forged_workflow_attribution() { + let relay = nostr::Keys::generate(); + let attacker = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let event = + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", workflow_owner.as_str()]) + .expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent.as_str()]) + .expect("workflow mention tag"), + nostr::Tag::parse(["p", agent.as_str()]).expect("recipient tag"), + ]) + .sign_with_keys(&attacker) + .expect("signed forged event"); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(attacker.public_key().to_hex(), false); + + let decision = evaluate_inbound_author_gate( + &event, + WorkflowAuthorContext { + relay_self: Some(&relay.public_key().to_hex()), + agent_pubkey_hex: &agent, + }, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &dummy_rest_client(), + ) + .await; + assert_eq!(decision.effective_author, attacker.public_key().to_hex()); + assert!( + !decision.allowed, + "an attacker-signed workflow event must not borrow trusted owner authority" ); } diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 7311185c932..d5f4875f8ef 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -71,7 +71,6 @@ pub(crate) enum AcpAvailabilityStatus { } use crate::{ - author_allowed, config::Config, event_mentions_agent, filter, relay::{HarnessRelay, RelayEventPublisher}, @@ -432,17 +431,21 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let author_hex = crate::effective_prompt_author(&buzz_event.event, relay_self.as_deref()); let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; - let allowed = author_allowed( + let author_gate = crate::evaluate_inbound_author_gate( + &buzz_event.event, + crate::WorkflowAuthorContext { + relay_self: relay_self.as_deref(), + agent_pubkey_hex: &pubkey_hex, + }, &config.respond_to, &config.respond_to_allowlist, - &author_hex, is_dm, &owner_cache, &rest_client, ) .await; + let allowed = author_gate.allowed; // Apply channel/kind filter rules. let filter_matched = filter::match_event( diff --git a/crates/buzz-relay/src/workflow_sink.rs b/crates/buzz-relay/src/workflow_sink.rs index 34d92d2fdd0..4ceb3b39308 100644 --- a/crates/buzz-relay/src/workflow_sink.rs +++ b/crates/buzz-relay/src/workflow_sink.rs @@ -148,6 +148,39 @@ fn resolve_mention_pubkeys(text: &str, members: &[(String, String)]) -> Vec, + rendered_text: &str, + authored_text: &str, + members: &[(String, String)], + author_pubkey_hex: &str, +) -> Result<(), ActionSinkError> { + let rendered_mentions = resolve_mention_pubkeys(rendered_text, members); + let authored_mentions: std::collections::HashSet = + resolve_mention_pubkeys(authored_text, members) + .into_iter() + .collect(); + + for mentioned in rendered_mentions { + if mentioned != author_pubkey_hex { + tags.push( + Tag::parse(["p", &mentioned]) + .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, + ); + } + if authored_mentions.contains(&mentioned) { + tags.push( + Tag::parse(["buzz:workflow-mention", &mentioned]).map_err(|e| { + ActionSinkError::EventBuild(format!("workflow mention tag: {e}")) + })?, + ); + } + } + Ok(()) +} + /// Relay-side action sink — executes workflow side-effects directly. /// /// Holds a **weak** reference to `AppState` to avoid an `Arc` reference cycle: @@ -175,11 +208,13 @@ impl ActionSink for RelayActionSink { community_id: CommunityId, channel_id: &str, text: &str, + authored_text: &str, author_pubkey: &str, reply_to: Option<&str>, ) -> Pin> + Send + '_>> { let channel_id = channel_id.to_owned(); let text = text.to_owned(); + let authored_text = authored_text.to_owned(); let author_pubkey = author_pubkey.to_owned(); let reply_to = reply_to.map(str::to_owned); @@ -259,8 +294,12 @@ impl ActionSink for RelayActionSink { // - `buzz:workflow` tag prevents recursive workflow triggering // - `buzz:workflow-owner` lets harnesses apply the owner's // inbound-author policy after verifying the relay signature - // - one `p` tag per `@Name` that resolves to a channel member, - // so mentioned agents are woken (wake is `p`-tag gated) + // - one `p` tag for every resolved mention in the rendered output, + // preserving legacy wake/feed behavior + // - one `buzz:workflow-mention` tag only when the same target was + // named in the workflow owner's stored step template. This is the + // authority-bearing provenance used by ACP; trigger-controlled + // template substitutions cannot create it. let mut tags = vec![ Tag::parse(["p", &author_pubkey_hex]) .map_err(|e| ActionSinkError::EventBuild(format!("p tag: {e}")))?, @@ -316,10 +355,13 @@ impl ActionSink for RelayActionSink { } } - // Resolve `@Name` mentions to channel-member pubkeys and append a - // `p` tag for each (skipping the author, already tagged above). A - // resolution failure must not drop the message, so log and proceed - // with the base tags. + // Resolve `@Name` mentions to channel-member pubkeys. The rendered + // text supplies the legacy `p` tags used by subscriptions and feeds. + // The stored author-written template independently supplies the + // authority-bearing workflow-mention tags. A trigger may therefore + // render an `@Name` into visible output, but it cannot borrow the + // workflow owner's authority to wake that agent. A resolution failure + // must not drop the message, so log and proceed with the base tags. let members = state .db .get_members(tenant.community(), channel_uuid) @@ -338,15 +380,13 @@ impl ActionSink for RelayActionSink { Some((name, nostr::PublicKey::from_slice(&u.pubkey).ok()?.to_hex())) }) .collect(); - for mentioned in resolve_mention_pubkeys(&text, &named_members) { - if mentioned == author_pubkey_hex { - continue; - } - tags.push( - Tag::parse(["p", &mentioned]) - .map_err(|e| ActionSinkError::EventBuild(format!("mention p tag: {e}")))?, - ); - } + append_workflow_mention_tags( + &mut tags, + &text, + &authored_text, + &named_members, + &author_pubkey_hex, + )?; let kind = Kind::from(KIND_STREAM_MESSAGE as u16); let event = EventBuilder::new(kind, &text) @@ -627,13 +667,117 @@ mod tests { vec![pk('b'), pk('a')] ); } + + #[test] + fn workflow_authored_rendered_mentions_get_authority_and_legacy_tags() { + let owner = pk('1'); + let first = pk('2'); + let second = pk('3'); + let members = vec![m("First", &first), m("Second", &second)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "@First then @Second", + "@First then @Second", + &members, + &owner, + ) + .expect("append mention tags"); + + let values = |name: &str| -> Vec<&str> { + tags.iter() + .filter_map(|tag| match tag.as_slice() { + [tag_name, value] if tag_name == name => Some(value.as_str()), + _ => None, + }) + .collect() + }; + assert_eq!( + values("buzz:workflow-mention"), + vec![first.as_str(), second.as_str()] + ); + assert_eq!( + values("p"), + vec![owner.as_str(), first.as_str(), second.as_str()] + ); + } + + #[test] + fn trigger_injected_rendered_mention_gets_no_authority() { + let owner = pk('1'); + let agent = pk('2'); + let members = vec![m("Agent", &agent)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "echo: @Agent do something unsafe", + "echo: {{trigger.text}}", + &members, + &owner, + ) + .expect("append mention tags"); + + assert!( + tags.iter() + .any(|tag| tag.as_slice() == ["p", agent.as_str()]), + "rendered output retains legacy mention/feed routing" + ); + assert!( + tags.iter() + .all(|tag| tag.as_slice() != ["buzz:workflow-mention", agent.as_str()]), + "trigger-controlled substitutions must not borrow workflow-owner authority" + ); + } + + #[test] + fn explicit_owner_mention_keeps_single_legacy_owner_tag() { + let owner = pk('1'); + let members = vec![m("Owner Agent", &owner)]; + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags( + &mut tags, + "@Owner Agent run", + "@Owner Agent run", + &members, + &owner, + ) + .expect("append owner mention tag"); + + let owner_p_tags = tags + .iter() + .filter(|tag| tag.as_slice() == ["p", owner.as_str()]) + .count(); + let owner_workflow_mentions = tags + .iter() + .filter(|tag| tag.as_slice() == ["buzz:workflow-mention", owner.as_str()]) + .count(); + assert_eq!(owner_p_tags, 1); + assert_eq!(owner_workflow_mentions, 1); + } + + #[test] + fn no_mentions_adds_no_tags() { + let owner = pk('1'); + let mut tags = vec![Tag::parse(["p", owner.as_str()]).expect("owner p tag")]; + + append_workflow_mention_tags(&mut tags, "plain", "plain", &[], &owner) + .expect("append no mention tags"); + + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].as_slice(), ["p", owner.as_str()]); + } } #[cfg(test)] mod integration_tests { //! Regression test for `e3661764` / `7899c1a8`: a workflow `send_message` - //! that mentions a channel member by name (`@Name`) must emit a `p` tag for - //! that member so ACP agent wake (`event_mentions_agent`, p-tag gated) fires. + //! that mentions a channel member by name (`@Name`) in its author-written + //! step template must emit both the legacy `p` tag and authenticated + //! workflow-mention provenance for that member. Rendered trigger data may + //! still create a legacy `p` tag, but never authority-bearing provenance. //! //! Postgres-gated like the other DB-backed relay tests. Run with: //! `cargo test -p buzz-relay --lib workflow_sink -- --ignored` @@ -680,9 +824,79 @@ mod integration_tests { Arc::new(state) } + async fn execute_send_message_workflow( + state: &Arc, + community: CommunityId, + channel_id: Uuid, + owner_pubkey: &[u8], + name: &str, + authored_text: &str, + trigger_text: &str, + ) -> String { + let definition = serde_json::json!({ + "name": name, + "trigger": {"on": "message_posted"}, + "steps": [{ + "id": "send", + "action": "send_message", + "text": authored_text, + }], + "enabled": true, + }); + let definition_hash_byte = name.as_bytes().first().copied().unwrap_or_default(); + let workflow_id = state + .db + .create_workflow( + community, + Some(channel_id), + owner_pubkey, + name, + &definition.to_string(), + &[definition_hash_byte; 32], + ) + .await + .expect("create workflow"); + let trigger_ctx = buzz_workflow::executor::TriggerContext { + text: trigger_text.to_owned(), + channel_id: channel_id.to_string(), + ..Default::default() + }; + let trigger_ctx_json = serde_json::to_value(&trigger_ctx).expect("serialize trigger"); + let run_id = state + .db + .create_workflow_run(community, workflow_id, None, Some(&trigger_ctx_json)) + .await + .expect("create workflow run"); + + // Load the definition back from Postgres before execution. This pins the + // authority source to the durable owner-authored template rather than a + // second test-only string passed directly to RelayActionSink. + let stored_workflow = state + .db + .get_workflow(community, workflow_id) + .await + .expect("load stored workflow"); + let stored_definition: buzz_workflow::WorkflowDef = + serde_json::from_value(stored_workflow.definition).expect("parse stored definition"); + let result = buzz_workflow::executor::execute_run( + &state.workflow_engine, + community, + run_id, + &stored_definition, + &trigger_ctx, + ) + .await + .expect("execute workflow"); + + result.step_outputs["send"]["event_id"] + .as_str() + .expect("send_message event id") + .to_owned() + } + #[tokio::test] #[ignore = "requires Postgres"] - async fn workflow_send_message_p_tags_mentioned_member() { + async fn workflow_send_message_binds_authority_to_authored_mentions() { let state = test_state().await; let author = nostr::Keys::generate(); @@ -703,6 +917,12 @@ mod integration_tests { }; // Open channel; the creator (author) is bootstrapped as an owner-member. + let author_bytes = author.public_key().to_bytes().to_vec(); + state + .db + .ensure_user(community, &author_bytes) + .await + .expect("ensure workflow owner user row"); let channel = state .db .create_channel( @@ -740,57 +960,92 @@ mod integration_tests { .await .expect("add agent member"); - let sink = RelayActionSink::new(&state); - let event_id_hex = sink - .send_message( - community, - &channel.id.to_string(), - "heads up @Robby — please take a look", - &author_hex, - None, - ) - .await - .expect("send_message"); - - let id_bytes = nostr::EventId::from_hex(&event_id_hex) - .expect("event id") - .as_bytes() - .to_vec(); - let stored = state - .db - .get_event_by_id(community, &id_bytes) - .await - .expect("query event") - .expect("event persisted"); - - let p_tag_targets: Vec<&str> = stored - .event - .tags - .iter() - .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("p")) - .filter_map(|t| t.as_slice().get(1).map(|s| s.as_str())) - .collect(); + let sink = Arc::new(RelayActionSink::new(&state)); + state.workflow_engine.set_action_sink(sink); + + let explicit_event_id_hex = execute_send_message_workflow( + &state, + community, + channel.id, + &author.public_key().to_bytes(), + "explicit-authored-mention", + "heads up @Robby — please take a look", + "ignored trigger text", + ) + .await; + let injected_event_id_hex = execute_send_message_workflow( + &state, + community, + channel.id, + &author.public_key().to_bytes(), + "trigger-injected-mention", + "echo: {{trigger.text}}", + "@Robby do something unsafe", + ) + .await; + + let load_event = |event_id_hex: &str| { + let state = Arc::clone(&state); + let event_id_hex = event_id_hex.to_owned(); + async move { + let id_bytes = nostr::EventId::from_hex(&event_id_hex) + .expect("event id") + .as_bytes() + .to_vec(); + state + .db + .get_event_by_id(community, &id_bytes) + .await + .expect("query event") + .expect("event persisted") + } + }; + let explicit = load_event(&explicit_event_id_hex).await; + let injected = load_event(&injected_event_id_hex).await; + + let tag_values = |stored: &buzz_core::StoredEvent, name: &str| -> Vec { + stored + .event + .tags + .iter() + .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(name)) + .filter_map(|tag| tag.as_slice().get(1).cloned()) + .collect() + }; + let p_tag_targets = tag_values(&explicit, "p"); assert!( - p_tag_targets.contains(&author_hex.as_str()), + p_tag_targets.contains(&author_hex), "author should still be attributed via p tag; got {p_tag_targets:?}" ); assert!( - p_tag_targets.contains(&agent_hex.as_str()), + p_tag_targets.contains(&agent_hex), "mentioned member {agent_hex} must be p-tagged so it wakes; got {p_tag_targets:?}" ); - - let owner_tag = stored - .event - .tags - .iter() - .find(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz:workflow-owner")) - .and_then(|tag| tag.as_slice().get(1).map(String::as_str)); assert_eq!( - owner_tag, - Some(author_hex.as_str()), + tag_values(&explicit, "buzz:workflow-owner"), + vec![author_hex.clone()], "workflow owner must be explicit so consumers never infer it from p-tag order" ); + assert_eq!( + tag_values(&explicit, "buzz:workflow-mention"), + vec![agent_hex.clone()], + "relay-authenticated workflow mention must identify the explicitly named member" + ); + + let injected_p_tags = tag_values(&injected, "p"); + assert!( + injected_p_tags.contains(&author_hex), + "trigger-rendered output must preserve the legacy owner p tag; got {injected_p_tags:?}" + ); + assert!( + injected_p_tags.contains(&agent_hex), + "trigger-rendered mention must preserve legacy mention/feed routing; got {injected_p_tags:?}" + ); + assert!( + tag_values(&injected, "buzz:workflow-mention").is_empty(), + "a mention introduced solely by trigger data must not receive owner-delegated authority" + ); } #[tokio::test] @@ -834,6 +1089,7 @@ mod integration_tests { community, &channel.id.to_string(), "root message", + "root message", &author_hex, None, ) @@ -846,6 +1102,7 @@ mod integration_tests { community, &channel.id.to_string(), "threaded reply", + "threaded reply", &author_hex, Some(&root_hex), ) @@ -988,6 +1245,7 @@ mod integration_tests { community, &channel_hex, "workflow reply", + "workflow reply", &author_hex, Some(&parent_hex), ) @@ -1069,6 +1327,7 @@ mod integration_tests { community, &channel_hex, "workflow reply to root-only parent", + "workflow reply to root-only parent", &author_hex, Some(&root_only_parent_hex), ) @@ -1131,6 +1390,7 @@ mod integration_tests { community, &channel.id.to_string(), "orphan reply", + "orphan reply", &author_hex, Some(&unknown), ) diff --git a/crates/buzz-workflow/src/action_sink.rs b/crates/buzz-workflow/src/action_sink.rs index 079c27a913d..b8c7f4dd809 100644 --- a/crates/buzz-workflow/src/action_sink.rs +++ b/crates/buzz-workflow/src/action_sink.rs @@ -54,7 +54,10 @@ pub trait ActionSink: Send + Sync { /// carries its owning community so a workflow in community B posts into B /// even though the side effect has no inbound connection to bind. /// - `channel_id`: UUID string of the target channel - /// - `text`: message body (must not be empty/whitespace-only) + /// - `text`: rendered message body (must not be empty/whitespace-only) + /// - `authored_text`: the workflow owner's stored, unrendered step template; + /// consumers must use this rather than trigger-controlled rendered output + /// when attaching authority-bearing metadata /// - `author_pubkey`: hex-encoded pubkey of the workflow owner (used for /// the `p` attribution tag; the relay keypair signs the event) /// - `reply_to`: when `Some(event_id_hex)`, the message is posted as a @@ -67,6 +70,7 @@ pub trait ActionSink: Send + Sync { community_id: CommunityId, channel_id: &str, text: &str, + authored_text: &str, author_pubkey: &str, reply_to: Option<&str>, ) -> Pin> + Send + '_>>; diff --git a/crates/buzz-workflow/src/executor.rs b/crates/buzz-workflow/src/executor.rs index 5c712dcff7c..90a6a02e020 100644 --- a/crates/buzz-workflow/src/executor.rs +++ b/crates/buzz-workflow/src/executor.rs @@ -535,7 +535,7 @@ fn resolve_send_message_channel( /// `RequestApproval` returns `StepResult::Suspended` — the caller must /// persist state and stop the execution loop. pub async fn dispatch_action( - step_id: &str, + step: &Step, action: &ActionDef, engine: &WorkflowEngine, community_id: CommunityId, @@ -544,6 +544,8 @@ pub async fn dispatch_action( ) -> Result { use ActionDef::*; + let step_id = &step.id; + // The workflow engine can outlive the serving request that spawned it. // Revalidate the durable community fence immediately before every external // side effect (message publish, webhook, delay/resume). A storage failure is @@ -622,12 +624,22 @@ pub async fn dispatch_action( "SendMessage → {channel_id}: {text}" ); + let authored_text = match &step.action { + SendMessage { text, .. } => text.as_str(), + _ => { + return Err(WorkflowError::InvalidDefinition( + "SendMessage: resolved action does not match its authored step" + .into(), + )); + } + }; let event_id = engine .action_sink()? .send_message( community_id, &channel_id, text, + authored_text, &owner_pubkey_hex, reply_to, ) @@ -1220,7 +1232,7 @@ async fn execute_steps( let dispatch_result = tokio::time::timeout( std::time::Duration::from_secs(timeout_secs), dispatch_action( - &step.id, + step, &resolved_action, engine, community_id, diff --git a/scripts/run-tests.sh b/scripts/run-tests.sh index 9dca8c82c37..6f7093084d7 100755 --- a/scripts/run-tests.sh +++ b/scripts/run-tests.sh @@ -120,6 +120,11 @@ run_unit_tests() { # `just test-unit` — the two lists must stay in step. run_test_step "buzz-agent unit tests" \ cargo test -p buzz-agent --lib -- --nocapture + + # ACP author-gate and queue tests are pure unit tests. Keep this fallback in + # step with `just test-unit`; ignored lifecycle tests run elsewhere. + run_test_step "buzz-acp unit tests" \ + cargo test -p buzz-acp --lib -- --nocapture } # ---- DB / integration tests (infra required) -------------------------------- From 0cc47e0921925d9f1333150bf88e829dd9565f26 Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Thu, 27 Aug 2026 21:30:20 -0600 Subject: [PATCH 03/13] test(acp): cover the listener-to-author-gate relay identity wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both listeners threaded a local relay identity into evaluate_inbound_author_gate on every event, so passing None at either call site silently disabled delegated workflow attribution — owner-only agents stop waking for their own workflows — while the whole buzz-acp suite stayed green. The new combined-gate tests called the helper directly and could not observe that wiring. Move the verified relay identity into an InboundAuthorGate owned by each listener: loaded in connect(), re-read in refresh() after a reconnect, and consulted by evaluate(). The per-event path no longer takes a relay identity argument, so the previous mutation is no longer expressible there, and dropping the load from construction or ignoring it in evaluate now fails a test. Add three regressions that build the gate through the same constructor the listeners use, against a stub NIP-11 document: a relay-signed workflow dispatch wakes an owner-only agent, a document without `self` falls back to the raw signer and stays closed, and a reconnect refresh re-arms attribution. No production behavior change. Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/lib.rs | 305 ++++++++++++++++++++++++++++-- crates/buzz-acp/src/setup_mode.rs | 31 ++- 2 files changed, 300 insertions(+), 36 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 566828c4cc3..c631781ea13 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -403,6 +403,79 @@ async fn evaluate_inbound_author_gate( } } +/// Owns the verified relay signing identity for a listener's lifetime and +/// applies the inbound author gate to each event. +/// +/// The relay identity is deliberately *not* a per-event parameter. Both +/// listeners previously threaded a local `Option` into every +/// `evaluate_inbound_author_gate` call, which meant the delegated-workflow +/// attribution this type exists to enforce could be silently disabled by +/// passing `None` at either call site while every unit test still passed. +/// Loading the identity in [`InboundAuthorGate::connect`] and refreshing it in +/// [`InboundAuthorGate::refresh`] leaves the per-event path with no relay +/// identity argument to drop, so wiring regressions become type errors or +/// failures of the construction-level regression tests rather than silent +/// availability loss. +struct InboundAuthorGate { + agent_pubkey_hex: String, + relay_self: Option, +} + +impl InboundAuthorGate { + /// Load the relay signing identity for a freshly connected listener. + async fn connect( + rest_client: &relay::RestClient, + agent_pubkey_hex: &str, + context: &str, + ) -> Self { + Self { + agent_pubkey_hex: agent_pubkey_hex.to_string(), + relay_self: refresh_relay_self(rest_client, None, context).await, + } + } + + /// Re-read the relay signing identity after a reconnect, retaining the last + /// verified key on a transient failure (see [`refresh_relay_self`]). + async fn refresh(&mut self, rest_client: &relay::RestClient, context: &str) { + self.relay_self = refresh_relay_self(rest_client, self.relay_self.take(), context).await; + } + + /// Whether delegated workflow attribution is currently available. + /// + /// Test-only: production code never branches on this. `refresh_relay_self` + /// already logs why attribution is unavailable, and every runtime path + /// treats a missing identity by falling back to the raw signer. + #[cfg(test)] + fn has_relay_identity(&self) -> bool { + self.relay_self.is_some() + } + + /// Resolve the effective author for `event` and apply the author policy. + async fn evaluate( + &self, + event: &nostr::Event, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + evaluate_inbound_author_gate( + event, + WorkflowAuthorContext { + relay_self: self.relay_self.as_deref(), + agent_pubkey_hex: &self.agent_pubkey_hex, + }, + respond_to, + allowlist, + is_dm, + owner_cache, + rest_client, + ) + .await + } +} + /// Refresh the relay signing identity, logging why delegated workflow /// attribution is unavailable. A transient fetch error keeps the last verified /// key so a reconnect blip cannot disable workflow wakes. That availability @@ -2181,7 +2254,8 @@ async fn tokio_main() -> Result<()> { tracing::info!("connected to relay at {}", config.relay_url); let relay_rest_client = relay.rest_client(); - let mut relay_self = refresh_relay_self(&relay_rest_client, None, "startup").await; + let mut author_gate_ctx = + InboundAuthorGate::connect(&relay_rest_client, &pubkey_hex, "startup").await; relay .subscribe_membership_notifications() @@ -3033,19 +3107,16 @@ async fn tokio_main() -> Result<()> { // it never revokes same-owner team bots. let is_dm = is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; - let author_gate = evaluate_inbound_author_gate( - &buzz_event.event, - WorkflowAuthorContext { - relay_self: relay_self.as_deref(), - agent_pubkey_hex: &pubkey_hex, - }, - &config.respond_to, - &config.respond_to_allowlist, - is_dm, - &owner_cache, - &ctx.rest_client, - ) - .await; + let author_gate = author_gate_ctx + .evaluate( + &buzz_event.event, + &config.respond_to, + &config.respond_to_allowlist, + is_dm, + &owner_cache, + &ctx.rest_client, + ) + .await; let author_hex = author_gate.effective_author; if !author_gate.allowed { tracing::debug!( @@ -3160,12 +3231,9 @@ async fn tokio_main() -> Result<()> { tokio::time::sleep(Duration::from_secs(1)).await; break; } - relay_self = refresh_relay_self( - &relay_rest_client, - relay_self, - "reconnect", - ) - .await; + author_gate_ctx + .refresh(&relay_rest_client, "reconnect") + .await; } } None @@ -5865,6 +5933,203 @@ mod author_gate_tests { cache } + /// Serve a NIP-11 document on a loopback port so `InboundAuthorGate` can be + /// built through the *same* constructor the listeners use, rather than by + /// injecting an already-resolved relay identity. This is what makes the + /// listener-to-gate wiring testable: a gate that never loads its identity + /// fails these tests instead of silently degrading to the raw signer. + async fn nip11_server( + document: serde_json::Value, + ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("bind NIP-11 test server"); + let base_url = format!("http://{}", listener.local_addr().unwrap()); + let body = document.to_string(); + let server = tokio::spawn(async move { + loop { + let Ok((mut socket, _)) = listener.accept().await else { + break; + }; + let mut request = vec![0; 8192]; + let _ = socket.read(&mut request).await; + 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.len(), + body + ); + let _ = socket.write_all(response.as_bytes()).await; + } + }); + let rest = relay::RestClient { + http: reqwest::Client::new(), + base_url, + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + (rest, server) + } + + /// A genuine relay-signed workflow dispatch that explicitly targets `agent` + /// on behalf of `owner` — the exact event shape a scheduled workflow emits. + fn relay_signed_workflow_dispatch( + relay_keys: &nostr::Keys, + owner: &str, + agent: &str, + ) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch") + .tags([ + nostr::Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"), + nostr::Tag::parse(["buzz:workflow-owner", owner]).expect("workflow owner tag"), + nostr::Tag::parse(["buzz:workflow-mention", agent]).expect("workflow mention tag"), + nostr::Tag::parse(["p", agent]).expect("recipient tag"), + ]) + .sign_with_keys(relay_keys) + .expect("signed workflow event") + } + + /// The listener-to-gate wiring regression. + /// + /// Both listeners build their gate with `InboundAuthorGate::connect` and + /// then call `evaluate` per event, with no relay-identity argument in + /// between. This test drives that exact sequence against a live NIP-11 + /// document, so it fails if the identity load is dropped from construction, + /// if `evaluate` stops consulting the loaded identity, or if attribution + /// inside the shared gate regresses to the raw relay signer — the wake + /// failure that motivated this change. + #[tokio::test] + async fn test_connected_gate_wakes_owner_only_agent_for_relay_signed_workflow() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + + let gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + assert!( + gate.has_relay_identity(), + "the gate must load the relay signing identity during construction" + ); + + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); + cache.cache_sibling(relay_hex.clone(), false); + + let decision = gate + .evaluate( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + + assert_eq!( + decision.effective_author, workflow_owner, + "a connected gate must attribute a relay-signed workflow dispatch to its owner, not the relay signer" + ); + assert!( + decision.allowed, + "an owner-only agent must wake for its own workflow's explicit mention" + ); + server.abort(); + } + + /// A gate whose relay identity is unavailable must fall back to the raw + /// signer and stay closed — the documented fail-closed behavior, and the + /// exact state the wiring regression above proves the listeners avoid. + #[tokio::test] + async fn test_gate_without_relay_identity_fails_closed_to_raw_signer() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + // A NIP-11 document with no `self` key: attribution is unavailable. + let (rest_client, server) = nip11_server(serde_json::json!({ "name": "relay" })).await; + + let gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + assert!( + !gate.has_relay_identity(), + "a NIP-11 document without `self` must leave attribution unavailable" + ); + + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner, true); + cache.cache_sibling(relay_hex.clone(), false); + + let decision = gate + .evaluate( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + + assert_eq!( + decision.effective_author, relay_hex, + "without a verified relay identity the gate must fall back to the raw signer" + ); + assert!( + !decision.allowed, + "unattributed relay-signed output must not wake an owner-only agent" + ); + server.abort(); + } + + /// A reconnect must re-arm attribution rather than leaving the listener + /// permanently degraded: `refresh` is the only path that updates the + /// identity after construction, and both listeners call it on reconnect. + #[tokio::test] + async fn test_gate_refresh_arms_attribution_after_reconnect() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + + // Construct against an unreachable relay: no identity yet. + let unreachable = relay::RestClient { + http: reqwest::Client::new(), + base_url: "http://127.0.0.1:1".into(), + keys: nostr::Keys::generate(), + auth_tag_json: None, + }; + let mut gate = InboundAuthorGate::connect(&unreachable, &agent, "test").await; + assert!(!gate.has_relay_identity()); + + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + gate.refresh(&rest_client, "test reconnect").await; + + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); + let cache = cache_with_sibling(); + cache.cache_sibling(workflow_owner.clone(), true); + + let decision = gate + .evaluate( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + assert_eq!( + decision.effective_author, workflow_owner, + "a reconnect refresh must restore delegated workflow attribution" + ); + assert!(decision.allowed); + server.abort(); + } + #[tokio::test] async fn test_combined_gate_accepts_explicit_trusted_workflow_target_only() { let relay = nostr::Keys::generate(); diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index d5f4875f8ef..83a84dba3dc 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -342,7 +342,8 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::info!("setup-mode: connected and subscribed to membership notifications"); let rest_client = relay.rest_client(); - let mut relay_self = crate::refresh_relay_self(&rest_client, None, "setup startup").await; + let mut author_gate_ctx = + crate::InboundAuthorGate::connect(&rest_client, &pubkey_hex, "setup startup").await; // Resolve owner for author-gate (same priority as normal mode). let startup_owner = crate::resolve_agent_owner(&config); @@ -396,8 +397,9 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::error!("setup-mode: relay background task is gone: {e} — exiting"); break; } - relay_self = - crate::refresh_relay_self(&rest_client, relay_self, "setup reconnect").await; + author_gate_ctx + .refresh(&rest_client, "setup reconnect") + .await; continue; }; @@ -432,19 +434,16 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; - let author_gate = crate::evaluate_inbound_author_gate( - &buzz_event.event, - crate::WorkflowAuthorContext { - relay_self: relay_self.as_deref(), - agent_pubkey_hex: &pubkey_hex, - }, - &config.respond_to, - &config.respond_to_allowlist, - is_dm, - &owner_cache, - &rest_client, - ) - .await; + let author_gate = author_gate_ctx + .evaluate( + &buzz_event.event, + &config.respond_to, + &config.respond_to_allowlist, + is_dm, + &owner_cache, + &rest_client, + ) + .await; let allowed = author_gate.allowed; // Apply channel/kind filter rules. From 56e7e0dcb0eaa17d2c7b3a1c9c4b946eb552e39c Mon Sep 17 00:00:00 2001 From: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 08:24:38 -0600 Subject: [PATCH 04/13] fix(acp): make relay-identity bypass a compile error, not a live seam Jude's review asked for a regression that bites if either listener's "relay identity input *or gate invocation*" is replaced with absent attribution. The previous commit closed the first half by moving the identity into `InboundAuthorGate`, but the second half stayed open: `evaluate_inbound_author_gate(..., relay_self: None, ...)` was still a free function in scope, and `InboundAuthorGate` fields were visible to the whole crate. Rewiring either listener to bypass the loaded identity still turned every delegated workflow wake off with 848/848 green. Encapsulate rather than test around it. `InboundAuthorGate` now lives in its own `inbound_author_gate` module with private fields, and the gate body is inlined into `evaluate`, so `connect` is the only way to build one. Constructing the gate with `relay_self: None` at either listener is now E0451, and the three existing regressions build their gate through the real `connect` path against a stub NIP-11 server. Mutation results at this commit: - evaluate() ignores loaded identity -> KILLED (3 tests) - connect() drops the identity load -> KILLED (2 tests) - reconnect refresh no-ops -> KILLED (1 test) - normal listener bypasses the gate -> COMPILE ERROR (E0451) - setup listener bypasses the gate -> COMPILE ERROR (E0451) No production behavior change: same attribution, same fail-closed fallback to the raw signer, same refresh points. cargo test -p buzz-acp: 848 lib + 9 lifecycle, 0 failed. Signed-off-by: Brain <1a02c72794dcd0f07058a353bc3a81f4028b8c77c92c87fce6d5c8b85970a20b@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/lib.rs | 278 +++++++++++++++++++------------------ 1 file changed, 141 insertions(+), 137 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index c631781ea13..64ace587f76 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -369,113 +369,100 @@ struct InboundAuthorGateDecision { allowed: bool, } -struct WorkflowAuthorContext<'a> { - relay_self: Option<&'a str>, - agent_pubkey_hex: &'a str, -} - -async fn evaluate_inbound_author_gate( - event: &nostr::Event, - workflow_author: WorkflowAuthorContext<'_>, - respond_to: &RespondTo, - allowlist: &HashSet, - is_dm: bool, - owner_cache: &OwnerCache, - rest_client: &relay::RestClient, -) -> InboundAuthorGateDecision { - let effective_author = effective_prompt_author( - event, - workflow_author.relay_self, - workflow_author.agent_pubkey_hex, - ); - let allowed = author_allowed( - respond_to, - allowlist, - &effective_author, - is_dm, - owner_cache, - rest_client, - ) - .await; - InboundAuthorGateDecision { - effective_author, - allowed, - } -} - /// Owns the verified relay signing identity for a listener's lifetime and /// applies the inbound author gate to each event. /// -/// The relay identity is deliberately *not* a per-event parameter. Both -/// listeners previously threaded a local `Option` into every -/// `evaluate_inbound_author_gate` call, which meant the delegated-workflow -/// attribution this type exists to enforce could be silently disabled by -/// passing `None` at either call site while every unit test still passed. -/// Loading the identity in [`InboundAuthorGate::connect`] and refreshing it in -/// [`InboundAuthorGate::refresh`] leaves the per-event path with no relay -/// identity argument to drop, so wiring regressions become type errors or -/// failures of the construction-level regression tests rather than silent -/// availability loss. -struct InboundAuthorGate { - agent_pubkey_hex: String, - relay_self: Option, -} +/// The relay identity is deliberately *not* a per-event parameter, and this +/// type deliberately lives in its own module with private fields so the only +/// way to obtain one is [`InboundAuthorGate::connect`], which loads the +/// identity. +/// +/// Two earlier revisions of this code were mutable-with-impunity: the first +/// threaded a local `Option` into every gate call, and the second kept +/// a free `evaluate_inbound_author_gate(.., relay_self, ..)` alongside the +/// method. In both cases a listener could be rewired to pass `None` — silently +/// disabling every delegated workflow wake — while all 848 tests stayed green. +/// Encapsulation, not a test, is what closes that seam: `InboundAuthorGate { +/// relay_self: None, .. }` is now a privacy error outside this module, and +/// dropping the load inside it fails the construction regressions. +mod inbound_author_gate { + use super::{ + author_allowed, effective_prompt_author, refresh_relay_self, relay, + InboundAuthorGateDecision, OwnerCache, RespondTo, + }; + use std::collections::HashSet; -impl InboundAuthorGate { - /// Load the relay signing identity for a freshly connected listener. - async fn connect( - rest_client: &relay::RestClient, - agent_pubkey_hex: &str, - context: &str, - ) -> Self { - Self { - agent_pubkey_hex: agent_pubkey_hex.to_string(), - relay_self: refresh_relay_self(rest_client, None, context).await, + pub(crate) struct InboundAuthorGate { + agent_pubkey_hex: String, + relay_self: Option, + } + + impl InboundAuthorGate { + /// Load the relay signing identity for a freshly connected listener. + pub(crate) async fn connect( + rest_client: &relay::RestClient, + agent_pubkey_hex: &str, + context: &str, + ) -> Self { + Self { + agent_pubkey_hex: agent_pubkey_hex.to_string(), + relay_self: refresh_relay_self(rest_client, None, context).await, + } } - } - /// Re-read the relay signing identity after a reconnect, retaining the last - /// verified key on a transient failure (see [`refresh_relay_self`]). - async fn refresh(&mut self, rest_client: &relay::RestClient, context: &str) { - self.relay_self = refresh_relay_self(rest_client, self.relay_self.take(), context).await; - } - - /// Whether delegated workflow attribution is currently available. - /// - /// Test-only: production code never branches on this. `refresh_relay_self` - /// already logs why attribution is unavailable, and every runtime path - /// treats a missing identity by falling back to the raw signer. - #[cfg(test)] - fn has_relay_identity(&self) -> bool { - self.relay_self.is_some() - } - - /// Resolve the effective author for `event` and apply the author policy. - async fn evaluate( - &self, - event: &nostr::Event, - respond_to: &RespondTo, - allowlist: &HashSet, - is_dm: bool, - owner_cache: &OwnerCache, - rest_client: &relay::RestClient, - ) -> InboundAuthorGateDecision { - evaluate_inbound_author_gate( - event, - WorkflowAuthorContext { - relay_self: self.relay_self.as_deref(), - agent_pubkey_hex: &self.agent_pubkey_hex, - }, - respond_to, - allowlist, - is_dm, - owner_cache, - rest_client, - ) - .await + /// Re-read the relay signing identity after a reconnect, retaining the + /// last verified key on a transient failure (see + /// [`refresh_relay_self`]). + pub(crate) async fn refresh(&mut self, rest_client: &relay::RestClient, context: &str) { + self.relay_self = + refresh_relay_self(rest_client, self.relay_self.take(), context).await; + } + + /// Whether delegated workflow attribution is currently available. + /// + /// Test-only: production code never branches on this. + /// `refresh_relay_self` already logs why attribution is unavailable, and + /// every runtime path treats a missing identity by falling back to the + /// raw signer. + #[cfg(test)] + pub(crate) fn has_relay_identity(&self) -> bool { + self.relay_self.is_some() + } + + /// Resolve the effective author for `event` and apply the author policy. + /// + /// The relay identity is read from `self`, never from a parameter, so no + /// caller can evaluate an event with attribution disabled. + pub(crate) async fn evaluate( + &self, + event: &nostr::Event, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + let effective_author = + effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex); + let allowed = author_allowed( + respond_to, + allowlist, + &effective_author, + is_dm, + owner_cache, + rest_client, + ) + .await; + InboundAuthorGateDecision { + effective_author, + allowed, + } + } } } +use inbound_author_gate::InboundAuthorGate; + /// Refresh the relay signing identity, logging why delegated workflow /// attribution is unavailable. A transient fetch error keeps the last verified /// key so a reconnect blip cannot disable workflow wakes. That availability @@ -5972,6 +5959,23 @@ mod author_gate_tests { (rest, server) } + /// Build a gate through the real `connect` path against a NIP-11 document + /// advertising `relay_hex` as the relay signer. Tests use this instead of + /// constructing `InboundAuthorGate` literally so that the identity load + /// stays part of what they cover. + async fn connected_gate( + relay_hex: &str, + agent: &str, + ) -> ( + InboundAuthorGate, + relay::RestClient, + tokio::task::JoinHandle<()>, + ) { + let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; + let gate = InboundAuthorGate::connect(&rest_client, agent, "test").await; + (gate, rest_client, server) + } + /// A genuine relay-signed workflow dispatch that explicitly targets `agent` /// on behalf of `owner` — the exact event shape a scheduled workflow emits. fn relay_signed_workflow_dispatch( @@ -6150,24 +6154,24 @@ mod author_gate_tests { let cache = cache_with_sibling(); cache.cache_sibling(workflow_owner.clone(), true); - let decision = evaluate_inbound_author_gate( - &event, - WorkflowAuthorContext { - relay_self: Some(&relay.public_key().to_hex()), - agent_pubkey_hex: &agent, - }, - &RespondTo::OwnerOnly, - &HashSet::new(), - false, - &cache, - &dummy_rest_client(), - ) - .await; + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; assert_eq!(decision.effective_author, workflow_owner); assert!( decision.allowed, "a verified workflow owner for an explicitly targeted agent must flow through the existing sibling policy" ); + server.abort(); } #[tokio::test] @@ -6189,19 +6193,19 @@ mod author_gate_tests { cache.cache_sibling(workflow_owner, true); cache.cache_sibling(relay.public_key().to_hex(), false); - let decision = evaluate_inbound_author_gate( - &event, - WorkflowAuthorContext { - relay_self: Some(&relay.public_key().to_hex()), - agent_pubkey_hex: &agent, - }, - &RespondTo::OwnerOnly, - &HashSet::new(), - false, - &cache, - &dummy_rest_client(), - ) - .await; + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + server.abort(); assert_eq!(decision.effective_author, relay.public_key().to_hex()); assert!( !decision.allowed, @@ -6231,19 +6235,19 @@ mod author_gate_tests { cache.cache_sibling(workflow_owner, true); cache.cache_sibling(attacker.public_key().to_hex(), false); - let decision = evaluate_inbound_author_gate( - &event, - WorkflowAuthorContext { - relay_self: Some(&relay.public_key().to_hex()), - agent_pubkey_hex: &agent, - }, - &RespondTo::OwnerOnly, - &HashSet::new(), - false, - &cache, - &dummy_rest_client(), - ) - .await; + let (gate, rest_client, server) = + connected_gate(&relay.public_key().to_hex(), &agent).await; + let decision = gate + .evaluate( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + false, + &cache, + &rest_client, + ) + .await; + server.abort(); assert_eq!(decision.effective_author, attacker.public_key().to_hex()); assert!( !decision.allowed, From 2884cb9855ed56c0664cd7bf889077fa898d8073 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 10:36:58 -0600 Subject: [PATCH 05/13] fix(acp): make listener author gate indivisible Move channel classification, workflow attribution, and raw-author policy behind the single event boundary used by both normal and setup listeners. Keep the raw policy private so either listener cannot regress to checking the relay signer directly, and exercise the production boundary in the owner-only workflow regression. Signed-off-by: Wes Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/lib.rs | 226 +++++++++++++++++++----------- crates/buzz-acp/src/setup_mode.rs | 6 +- 2 files changed, 150 insertions(+), 82 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 64ace587f76..ea715fc3d3e 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -233,48 +233,6 @@ async fn is_owner_or_sibling( is_sibling } -/// Inbound author gate decision: does this author's event fire a turn? -/// -/// Coarse security policy applied before subscription rules. Both `OwnerOnly` -/// and `Allowlist` accept the owner and same-owner siblings; `Allowlist` -/// additionally accepts the explicit external pubkey list. -/// -/// # DM hardening (`is_dm`) -/// -/// Clients auto-p-tag every DM participant, so in a DM *any* participant's -/// message looks like a mention and would fire a turn. Combined with -/// agent-initiated DMs (the agent can be asked to DM a third party), that -/// turns `anyone`/`allowlist` modes into transitive access grants: whoever -/// lands in a DM with the agent can prompt it. To close that hole, when -/// `is_dm` is true only the owner and cryptographically verified same-owner -/// siblings may fire a turn — the explicit allowlist and `anyone` mode do -/// NOT apply inside DMs. `Nobody` still drops everything. Callers must -/// resolve `is_dm` fail-closed: unknown channel type ⇒ treat as DM. -async fn author_allowed( - respond_to: &RespondTo, - allowlist: &HashSet, - author: &str, - is_dm: bool, - owner_cache: &OwnerCache, - rest_client: &relay::RestClient, -) -> bool { - if is_dm { - return match respond_to { - RespondTo::Nobody => false, - _ => is_owner_or_sibling(author, owner_cache, rest_client).await, - }; - } - match respond_to { - RespondTo::Anyone => true, - RespondTo::Nobody => false, - RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, - RespondTo::Allowlist => { - allowlist.contains(author) - || is_owner_or_sibling(author, owner_cache, rest_client).await - } - } -} - /// Return the workflow owner attributed by a relay-signed workflow message. /// /// `buzz:workflow-owner` alone is not authority: any ordinary event author can @@ -367,6 +325,7 @@ fn effective_prompt_author( struct InboundAuthorGateDecision { effective_author: String, allowed: bool, + is_dm: bool, } /// Owns the verified relay signing identity for a listener's lifetime and @@ -387,10 +346,60 @@ struct InboundAuthorGateDecision { /// dropping the load inside it fails the construction regressions. mod inbound_author_gate { use super::{ - author_allowed, effective_prompt_author, refresh_relay_self, relay, - InboundAuthorGateDecision, OwnerCache, RespondTo, + effective_prompt_author, is_dm_channel, is_owner_or_sibling, pool, refresh_relay_self, + relay, InboundAuthorGateDecision, OwnerCache, RespondTo, }; use std::collections::HashSet; + use uuid::Uuid; + + /// Apply the configured raw-author policy after trusted workflow attribution. + /// + /// This stays private to the gate module so neither listener can bypass + /// workflow attribution by calling the raw-signer policy directly. + async fn author_allowed( + respond_to: &RespondTo, + allowlist: &HashSet, + author: &str, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> bool { + if is_dm { + return match respond_to { + RespondTo::Nobody => false, + _ => is_owner_or_sibling(author, owner_cache, rest_client).await, + }; + } + match respond_to { + RespondTo::Anyone => true, + RespondTo::Nobody => false, + RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::Allowlist => { + allowlist.contains(author) + || is_owner_or_sibling(author, owner_cache, rest_client).await + } + } + } + + #[cfg(test)] + pub(super) async fn test_author_allowed( + respond_to: &RespondTo, + allowlist: &HashSet, + author: &str, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> bool { + author_allowed( + respond_to, + allowlist, + author, + is_dm, + owner_cache, + rest_client, + ) + .await + } pub(crate) struct InboundAuthorGate { agent_pubkey_hex: String, @@ -429,11 +438,35 @@ mod inbound_author_gate { self.relay_self.is_some() } - /// Resolve the effective author for `event` and apply the author policy. + /// Resolve channel trust, trusted workflow attribution, and author policy + /// for one listener event. /// - /// The relay identity is read from `self`, never from a parameter, so no - /// caller can evaluate an event with attribution disabled. - pub(crate) async fn evaluate( + /// Both production listeners call this exact boundary. The raw-author + /// policy and relay identity are private to this module, so replacing a + /// listener call with raw-signer authorization is not expressible. + pub(crate) async fn evaluate_listener_event( + &self, + event: &nostr::Event, + channel_id: Uuid, + respond_to: &RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + let is_dm = is_dm_channel(channel_id, channel_info).await; + self.evaluate_with_channel_trust( + event, + respond_to, + allowlist, + is_dm, + owner_cache, + rest_client, + ) + .await + } + + async fn evaluate_with_channel_trust( &self, event: &nostr::Event, respond_to: &RespondTo, @@ -456,8 +489,30 @@ mod inbound_author_gate { InboundAuthorGateDecision { effective_author, allowed, + is_dm, } } + + #[cfg(test)] + pub(crate) async fn evaluate_for_test( + &self, + event: &nostr::Event, + respond_to: &RespondTo, + allowlist: &HashSet, + is_dm: bool, + owner_cache: &OwnerCache, + rest_client: &relay::RestClient, + ) -> InboundAuthorGateDecision { + self.evaluate_with_channel_trust( + event, + respond_to, + allowlist, + is_dm, + owner_cache, + rest_client, + ) + .await + } } } @@ -3092,15 +3147,14 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. - let is_dm = - is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await; let author_gate = author_gate_ctx - .evaluate( + .evaluate_listener_event( &buzz_event.event, + buzz_event.channel_id, &config.respond_to, &config.respond_to_allowlist, - is_dm, &owner_cache, + &ctx.channel_info, &ctx.rest_client, ) .await; @@ -3111,7 +3165,7 @@ async fn tokio_main() -> Result<()> { raw_author = %buzz_event.event.pubkey.to_hex(), effective_author = %author_hex, mode = %config.respond_to, - is_dm, + is_dm = author_gate.is_dm, "inbound author gate — dropping event" ); continue; @@ -5994,15 +6048,16 @@ mod author_gate_tests { .expect("signed workflow event") } - /// The listener-to-gate wiring regression. + /// The listener decision-boundary regression. /// - /// Both listeners build their gate with `InboundAuthorGate::connect` and - /// then call `evaluate` per event, with no relay-identity argument in - /// between. This test drives that exact sequence against a live NIP-11 - /// document, so it fails if the identity load is dropped from construction, - /// if `evaluate` stops consulting the loaded identity, or if attribution - /// inside the shared gate regresses to the raw relay signer — the wake - /// failure that motivated this change. + /// Both listeners call `evaluate_listener_event`; it owns channel trust, + /// workflow attribution, and raw-author policy, with no production-visible + /// raw-policy helper alongside it. This test drives that exact callable + /// against a live NIP-11 document, so it fails if identity loading, + /// effective-author resolution, DM classification, or policy application + /// regresses. Replacing either listener call with the former raw-signer + /// `author_allowed` path is now a compile error because that policy is + /// private to the gate module. #[tokio::test] async fn test_connected_gate_wakes_owner_only_agent_for_relay_signed_workflow() { let relay_keys = nostr::Keys::generate(); @@ -6022,13 +6077,26 @@ mod author_gate_tests { cache.cache_sibling(workflow_owner.clone(), true); cache.cache_sibling(relay_hex.clone(), false); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); let decision = gate - .evaluate( + .evaluate_listener_event( &event, + channel_id, &RespondTo::OwnerOnly, &HashSet::new(), - false, &cache, + &channel_info, &rest_client, ) .await; @@ -6068,7 +6136,7 @@ mod author_gate_tests { cache.cache_sibling(relay_hex.clone(), false); let decision = gate - .evaluate( + .evaluate_for_test( &event, &RespondTo::OwnerOnly, &HashSet::new(), @@ -6117,7 +6185,7 @@ mod author_gate_tests { cache.cache_sibling(workflow_owner.clone(), true); let decision = gate - .evaluate( + .evaluate_for_test( &event, &RespondTo::OwnerOnly, &HashSet::new(), @@ -6157,7 +6225,7 @@ mod author_gate_tests { let (gate, rest_client, server) = connected_gate(&relay.public_key().to_hex(), &agent).await; let decision = gate - .evaluate( + .evaluate_for_test( &event, &RespondTo::OwnerOnly, &HashSet::new(), @@ -6196,7 +6264,7 @@ mod author_gate_tests { let (gate, rest_client, server) = connected_gate(&relay.public_key().to_hex(), &agent).await; let decision = gate - .evaluate( + .evaluate_for_test( &event, &RespondTo::OwnerOnly, &HashSet::new(), @@ -6238,7 +6306,7 @@ mod author_gate_tests { let (gate, rest_client, server) = connected_gate(&relay.public_key().to_hex(), &agent).await; let decision = gate - .evaluate( + .evaluate_for_test( &event, &RespondTo::OwnerOnly, &HashSet::new(), @@ -6260,7 +6328,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, SIBLING, @@ -6278,7 +6346,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -6296,7 +6364,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, STRANGER, @@ -6314,7 +6382,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::new(); assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, OWNER, @@ -6335,7 +6403,7 @@ mod author_gate_tests { async fn test_owner_only_rejects_stranger_so_no_steer() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::OwnerOnly, &HashSet::new(), STRANGER, @@ -6353,7 +6421,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &RespondTo::OwnerOnly, &HashSet::new(), who, @@ -6379,7 +6447,7 @@ mod author_gate_tests { let cache = cache_with_sibling(); let allowlist = HashSet::from([EXTERNAL.to_string()]); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, @@ -6396,7 +6464,7 @@ mod author_gate_tests { async fn test_dm_rejects_stranger_under_anyone() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Anyone, &HashSet::new(), STRANGER, @@ -6419,7 +6487,7 @@ mod author_gate_tests { ] { for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { assert!( - author_allowed( + inbound_author_gate::test_author_allowed( &mode, &HashSet::new(), who, @@ -6438,7 +6506,7 @@ mod author_gate_tests { async fn test_dm_nobody_rejects_even_owner() { let cache = cache_with_sibling(); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Nobody, &HashSet::new(), OWNER, @@ -6578,7 +6646,7 @@ mod author_gate_tests { let is_dm = is_dm_channel(id, &channel_info).await; assert!(is_dm, "unknown startup metadata must fail closed as DM"); assert!( - !author_allowed( + !inbound_author_gate::test_author_allowed( &RespondTo::Allowlist, &allowlist, EXTERNAL, diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 83a84dba3dc..9c6d860d646 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -433,14 +433,14 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await; let author_gate = author_gate_ctx - .evaluate( + .evaluate_listener_event( &buzz_event.event, + buzz_event.channel_id, &config.respond_to, &config.respond_to_allowlist, - is_dm, &owner_cache, + &channel_info, &rest_client, ) .await; From ad385be9c6c9f68c3f7341c137a658c141ec7c9e Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 10:49:26 -0600 Subject: [PATCH 06/13] fix(acp): pass listener events through author gate Pass the relay event envelope into the indivisible author gate so channel identity and event identity remain one input while satisfying the workspace Clippy argument limit. Signed-off-by: Wes Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/lib.rs | 15 ++++++--------- crates/buzz-acp/src/setup_mode.rs | 3 +-- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index ea715fc3d3e..1391ba71705 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -350,7 +350,6 @@ mod inbound_author_gate { relay, InboundAuthorGateDecision, OwnerCache, RespondTo, }; use std::collections::HashSet; - use uuid::Uuid; /// Apply the configured raw-author policy after trusted workflow attribution. /// @@ -446,17 +445,16 @@ mod inbound_author_gate { /// listener call with raw-signer authorization is not expressible. pub(crate) async fn evaluate_listener_event( &self, - event: &nostr::Event, - channel_id: Uuid, + buzz_event: &relay::BuzzEvent, respond_to: &RespondTo, allowlist: &HashSet, owner_cache: &OwnerCache, channel_info: &pool::ChannelInfoResolver, rest_client: &relay::RestClient, ) -> InboundAuthorGateDecision { - let is_dm = is_dm_channel(channel_id, channel_info).await; + let is_dm = is_dm_channel(buzz_event.channel_id, channel_info).await; self.evaluate_with_channel_trust( - event, + &buzz_event.event, respond_to, allowlist, is_dm, @@ -3149,8 +3147,7 @@ async fn tokio_main() -> Result<()> { // it never revokes same-owner team bots. let author_gate = author_gate_ctx .evaluate_listener_event( - &buzz_event.event, - buzz_event.channel_id, + &buzz_event, &config.respond_to, &config.respond_to_allowlist, &owner_cache, @@ -6089,10 +6086,10 @@ mod author_gate_tests { )]), rest_client.clone(), ); + let buzz_event = relay::BuzzEvent { channel_id, event }; let decision = gate .evaluate_listener_event( - &event, - channel_id, + &buzz_event, &RespondTo::OwnerOnly, &HashSet::new(), &cache, diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 9c6d860d646..60c82959781 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -435,8 +435,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // in DMs only owner/siblings get a nudge (fail-closed on unknown type). let author_gate = author_gate_ctx .evaluate_listener_event( - &buzz_event.event, - buzz_event.channel_id, + &buzz_event, &config.respond_to, &config.respond_to_allowlist, &owner_cache, From 6039d7cf247031a7be3d33c291e36261d4e4c059 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 11:30:23 -0600 Subject: [PATCH 07/13] fix(acp): refresh relay identity by connection Stamp delivered events with their authenticated connection generation and refresh NIP-11 before either listener authorizes the first event from a recovered socket. Preserve the last verified key through failed refreshes, then rotate it on the next successful generation. Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-acp/src/lib.rs | 185 +++++++++++++++++++++++++++++- crates/buzz-acp/src/relay.rs | 13 +++ crates/buzz-acp/src/setup_mode.rs | 10 ++ 3 files changed, 206 insertions(+), 2 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1391ba71705..c1004b94dda 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -405,6 +405,10 @@ mod inbound_author_gate { relay_self: Option, } + pub(crate) fn refresh_needed(refreshed_generation: u64, event_generation: u64) -> bool { + event_generation > refreshed_generation + } + impl InboundAuthorGate { /// Load the relay signing identity for a freshly connected listener. pub(crate) async fn connect( @@ -437,6 +441,11 @@ mod inbound_author_gate { self.relay_self.is_some() } + #[cfg(test)] + pub(crate) fn relay_identity_for_test(&self) -> Option<&str> { + self.relay_self.as_deref() + } + /// Resolve channel trust, trusted workflow attribution, and author policy /// for one listener event. /// @@ -514,6 +523,21 @@ mod inbound_author_gate { } } +async fn refresh_author_gate_for_generation( + author_gate: &mut InboundAuthorGate, + rest_client: &relay::RestClient, + refreshed_generation: &mut u64, + event_generation: u64, + context: &str, +) { + if !inbound_author_gate::refresh_needed(*refreshed_generation, event_generation) { + return; + } + + author_gate.refresh(rest_client, context).await; + *refreshed_generation = event_generation; +} + use inbound_author_gate::InboundAuthorGate; /// Refresh the relay signing identity, logging why delegated workflow @@ -2296,6 +2320,7 @@ async fn tokio_main() -> Result<()> { let relay_rest_client = relay.rest_client(); let mut author_gate_ctx = InboundAuthorGate::connect(&relay_rest_client, &pubkey_hex, "startup").await; + let mut refreshed_relay_generation = 0u64; relay .subscribe_membership_notifications() @@ -2908,6 +2933,14 @@ async fn tokio_main() -> Result<()> { let _ = result_rx; // end split borrow before relay handling match buzz_event { Some(buzz_event) => { + refresh_author_gate_for_generation( + &mut author_gate_ctx, + &relay_rest_client, + &mut refreshed_relay_generation, + buzz_event.connection_generation, + "reconnect", + ) + .await; let kind_u32 = buzz_event.event.kind.as_u16() as u32; if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION @@ -5978,6 +6011,13 @@ mod author_gate_tests { /// fails these tests instead of silently degrading to the raw signer. async fn nip11_server( document: serde_json::Value, + ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { + nip11_scripted_server(std::collections::VecDeque::from([Ok(document)])).await + } + + /// Serve scripted NIP-11 responses. `Err(())` returns HTTP 500. + async fn nip11_scripted_server( + responses: std::collections::VecDeque>, ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -5985,7 +6025,7 @@ mod author_gate_tests { .await .expect("bind NIP-11 test server"); let base_url = format!("http://{}", listener.local_addr().unwrap()); - let body = document.to_string(); + let responses = std::sync::Arc::new(tokio::sync::Mutex::new((responses, None))); let server = tokio::spawn(async move { loop { let Ok((mut socket, _)) = listener.accept().await else { @@ -5993,6 +6033,27 @@ mod author_gate_tests { }; let mut request = vec![0; 8192]; let _ = socket.read(&mut request).await; + let response = { + let mut scripted = responses.lock().await; + let response = if let Some(next) = scripted.0.pop_front() { + Some(next) + } else { + scripted.1.clone() + }; + if let Some(Ok(document)) = &response { + scripted.1 = Some(Ok(document.clone())); + } + response + }; + let Some(response) = response else { + continue; + }; + let Ok(document) = response else { + let response = "HTTP/1.1 500 Internal Server Error\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"; + let _ = socket.write_all(response.as_bytes()).await; + continue; + }; + let body = document.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.len(), @@ -6086,7 +6147,11 @@ mod author_gate_tests { )]), rest_client.clone(), ); - let buzz_event = relay::BuzzEvent { channel_id, event }; + let buzz_event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event, + }; let decision = gate .evaluate_listener_event( &buzz_event, @@ -6199,6 +6264,122 @@ mod author_gate_tests { server.abort(); } + #[test] + fn listener_refreshes_exactly_once_per_connection_generation() { + assert!(!super::inbound_author_gate::refresh_needed(0, 0)); + assert!(super::inbound_author_gate::refresh_needed(0, 1)); + assert!(!super::inbound_author_gate::refresh_needed(1, 1)); + assert!(super::inbound_author_gate::refresh_needed(1, 2)); + } + + #[tokio::test] + async fn test_first_event_after_reconnect_refreshes_rotated_relay_identity() { + let old_relay = nostr::Keys::generate(); + let new_relay = nostr::Keys::generate(); + let old_relay_hex = old_relay.public_key().to_hex(); + let new_relay_hex = new_relay.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let channel_id = uuid::Uuid::new_v4(); + let (rest_client, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Ok(serde_json::json!({ "self": old_relay_hex.clone() })), + Err(()), + Err(()), + Ok(serde_json::json!({ "self": new_relay_hex.clone() })), + ])) + .await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(old_relay_hex.clone(), false); + owner_cache.cache_sibling(new_relay_hex.clone(), false); + let channel_info = pool::ChannelInfoResolver::new( + std::collections::HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "test".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let mut refreshed_generation = 1; + + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); + gate.refresh(&rest_client, "outage").await; + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); + let retained_old_event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: relay_signed_workflow_dispatch(&old_relay, &workflow_owner, &agent), + }; + let retained = gate + .evaluate_listener_event( + &retained_old_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert!( + retained.allowed, + "a failed refresh must retain the last verified relay key; effective author={} raw={}", + retained.effective_author, + retained_old_event.event.pubkey.to_hex() + ); + + let new_event = relay::BuzzEvent { + connection_generation: 2, + channel_id, + event: relay_signed_workflow_dispatch(&new_relay, &workflow_owner, &agent), + }; + refresh_author_gate_for_generation( + &mut gate, + &rest_client, + &mut refreshed_generation, + new_event.connection_generation, + "reconnect", + ) + .await; + assert_eq!(gate.relay_identity_for_test(), Some(new_relay_hex.as_str())); + + let stale_old_event = relay::BuzzEvent { + connection_generation: 2, + channel_id, + event: relay_signed_workflow_dispatch(&old_relay, &workflow_owner, &agent), + }; + let stale = gate + .evaluate_listener_event( + &stale_old_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert!(!stale.allowed, "the rotated-away relay key must be evicted"); + + let first_new = gate + .evaluate_listener_event( + &new_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + assert_eq!(first_new.effective_author, workflow_owner); + assert!( + first_new.allowed, + "the first event from the new connection must use the refreshed relay key" + ); + server.abort(); + } + #[tokio::test] async fn test_combined_gate_accepts_explicit_trusted_workflow_target_only() { let relay = nostr::Keys::generate(); diff --git a/crates/buzz-acp/src/relay.rs b/crates/buzz-acp/src/relay.rs index 5f1aa8dff3c..26644805bbf 100644 --- a/crates/buzz-acp/src/relay.rs +++ b/crates/buzz-acp/src/relay.rs @@ -584,6 +584,10 @@ impl RestClient { /// Events the harness cares about. #[derive(Debug, Clone)] pub struct BuzzEvent { + /// Which authenticated relay connection delivered this event. Generation 0 + /// is the initial connection; each successful reconnect increments it + /// before any buffered or live event from that connection is forwarded. + pub connection_generation: u64, /// Which channel this event belongs to. pub channel_id: Uuid, /// The underlying Nostr event. @@ -1209,6 +1213,10 @@ struct BgState { /// A single failed channel REQ is parked here instead of aborting the whole /// reconnect. Drained by the main loop. Flushed on each reconnect attempt. resubscribe_retry: HashSet, + /// Current authenticated WebSocket generation. Incremented immediately + /// after each successful reconnect handshake, before buffered or live + /// events from the new connection are forwarded. + connection_generation: u64, /// Current position in the exponential backoff ladder. /// /// Persisted across calls to `wait_for_reconnect` so a flapping link stays at @@ -1240,6 +1248,7 @@ impl BgState { observer_in_flight: VecDeque::new(), gated_observer_dropped: 0, resubscribe_retry: HashSet::new(), + connection_generation: 0, backoff_step: 0, } } @@ -2258,6 +2267,7 @@ async fn handle_ws_message( } let ts = event.created_at.as_secs(); let buzz_event = BuzzEvent { + connection_generation: state.connection_generation, channel_id: channel_uuid, event: *event, }; @@ -2299,6 +2309,7 @@ async fn handle_ws_message( let event_id_hex = event.id.to_hex(); if state.record_event(channel_id, &event) { let buzz_event = BuzzEvent { + connection_generation: state.connection_generation, channel_id, event: *event, }; @@ -3082,6 +3093,7 @@ async fn try_autonomous_reconnect( match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; + state.connection_generation = state.connection_generation.saturating_add(1); info!("autonomous reconnect succeeded (attempt {})", attempt + 1); let handshake_ok = process_handshake_buffer( ws, @@ -3220,6 +3232,7 @@ async fn wait_for_reconnect( match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; + state.connection_generation = state.connection_generation.saturating_add(1); info!("relay reconnected to {relay_url}"); let handshake_ok = process_handshake_buffer( ws, diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 60c82959781..1caa07dcd21 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -344,6 +344,7 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> let rest_client = relay.rest_client(); let mut author_gate_ctx = crate::InboundAuthorGate::connect(&rest_client, &pubkey_hex, "setup startup").await; + let mut refreshed_relay_generation = 0u64; // Resolve owner for author-gate (same priority as normal mode). let startup_owner = crate::resolve_agent_owner(&config); @@ -403,6 +404,15 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> continue; }; + crate::refresh_author_gate_for_generation( + &mut author_gate_ctx, + &rest_client, + &mut refreshed_relay_generation, + buzz_event.connection_generation, + "setup reconnect", + ) + .await; + let kind_u32 = buzz_event.event.kind.as_u16() as u32; // Handle membership notifications so we subscribe to new channels From 87462054d5120650100ad089323c4ae7d3431c5d Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 11:48:22 -0600 Subject: [PATCH 08/13] fix(acp): retry failed relay identity refresh Keep a recovered connection generation pending when NIP-11 fails so the next event retries identity discovery before authorization. Treat successful documents without a self key as authoritative and preserve the last good identity only across transient failures. Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-acp/src/lib.rs | 78 ++++++++++++++++++++++---------------- 1 file changed, 46 insertions(+), 32 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index c1004b94dda..d5be58460b6 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -418,16 +418,23 @@ mod inbound_author_gate { ) -> Self { Self { agent_pubkey_hex: agent_pubkey_hex.to_string(), - relay_self: refresh_relay_self(rest_client, None, context).await, + relay_self: refresh_relay_self(rest_client, None, context).await.0, } } /// Re-read the relay signing identity after a reconnect, retaining the /// last verified key on a transient failure (see - /// [`refresh_relay_self`]). - pub(crate) async fn refresh(&mut self, rest_client: &relay::RestClient, context: &str) { - self.relay_self = + /// [`refresh_relay_self`]). Returns whether NIP-11 produced an + /// authoritative document, including one without a `self` key. + pub(crate) async fn refresh( + &mut self, + rest_client: &relay::RestClient, + context: &str, + ) -> bool { + let (relay_self, completed) = refresh_relay_self(rest_client, self.relay_self.take(), context).await; + self.relay_self = relay_self; + completed } /// Whether delegated workflow attribution is currently available. @@ -534,8 +541,9 @@ async fn refresh_author_gate_for_generation( return; } - author_gate.refresh(rest_client, context).await; - *refreshed_generation = event_generation; + if author_gate.refresh(rest_client, context).await { + *refreshed_generation = event_generation; + } } use inbound_author_gate::InboundAuthorGate; @@ -551,15 +559,15 @@ async fn refresh_relay_self( rest_client: &relay::RestClient, current: Option, context: &str, -) -> Option { +) -> (Option, bool) { match rest_client.relay_self().await { - Ok(Some(pubkey)) => Some(pubkey), + Ok(Some(pubkey)) => (Some(pubkey), true), Ok(None) => { tracing::warn!( %context, "relay NIP-11 document has no `self` key — workflow attribution remains fail-closed" ); - None + (None, true) } Err(error) => { tracing::warn!( @@ -568,7 +576,7 @@ async fn refresh_relay_self( retaining_previous_identity = current.is_some(), "failed to refresh relay NIP-11 identity" ); - current + (current, false) } } } @@ -5716,10 +5724,10 @@ mod workflow_owner_tests { auth_tag_json: None, }; - assert_eq!( - refresh_relay_self(&client, Some(previous.clone()), "test").await, - Some(previous) - ); + let (refreshed, completed) = + refresh_relay_self(&client, Some(previous.clone()), "test").await; + assert_eq!(refreshed, Some(previous)); + assert!(!completed); } #[test] @@ -6273,7 +6281,7 @@ mod author_gate_tests { } #[tokio::test] - async fn test_first_event_after_reconnect_refreshes_rotated_relay_identity() { + async fn test_generation_refresh_retries_after_nip11_failure() { let old_relay = nostr::Keys::generate(); let new_relay = nostr::Keys::generate(); let old_relay_hex = old_relay.public_key().to_hex(); @@ -6306,16 +6314,28 @@ mod author_gate_tests { let mut refreshed_generation = 1; assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); - gate.refresh(&rest_client, "outage").await; - assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); - let retained_old_event = relay::BuzzEvent { - connection_generation: 0, + + let new_event = relay::BuzzEvent { + connection_generation: 2, channel_id, - event: relay_signed_workflow_dispatch(&old_relay, &workflow_owner, &agent), + event: relay_signed_workflow_dispatch(&new_relay, &workflow_owner, &agent), }; - let retained = gate + refresh_author_gate_for_generation( + &mut gate, + &rest_client, + &mut refreshed_generation, + new_event.connection_generation, + "reconnect", + ) + .await; + assert_eq!( + refreshed_generation, 1, + "a failed NIP-11 refresh must leave the generation pending" + ); + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); + let first_new = gate .evaluate_listener_event( - &retained_old_event, + &new_event, &RespondTo::OwnerOnly, &HashSet::new(), &owner_cache, @@ -6324,25 +6344,19 @@ mod author_gate_tests { ) .await; assert!( - retained.allowed, - "a failed refresh must retain the last verified relay key; effective author={} raw={}", - retained.effective_author, - retained_old_event.event.pubkey.to_hex() + !first_new.allowed, + "the new signer must remain fail-closed while NIP-11 is unavailable" ); - let new_event = relay::BuzzEvent { - connection_generation: 2, - channel_id, - event: relay_signed_workflow_dispatch(&new_relay, &workflow_owner, &agent), - }; refresh_author_gate_for_generation( &mut gate, &rest_client, &mut refreshed_generation, new_event.connection_generation, - "reconnect", + "reconnect retry", ) .await; + assert_eq!(refreshed_generation, 2); assert_eq!(gate.relay_identity_for_test(), Some(new_relay_hex.as_str())); let stale_old_event = relay::BuzzEvent { From 6a3bff3f4413c277b39f803793c6cdaa57ec2a52 Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 12:29:33 -0600 Subject: [PATCH 09/13] fix(acp): retry startup relay identity discovery Keep identity refresh completion inside the author gate as an optional connection generation. A failed startup lookup stays pending and retries on generation-zero events; authoritative results with or without a self key complete startup normally. Both listeners share the same state, preserving reconnect retry behavior. Cover failed-startup recovery through the production generation and author-decision methods, and verify successful startup does not refetch on the initial connection. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/lib.rs | 187 ++++++++++++++++++++++-------- crates/buzz-acp/src/setup_mode.rs | 16 ++- 2 files changed, 143 insertions(+), 60 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index d5be58460b6..650282746ee 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -403,10 +403,12 @@ mod inbound_author_gate { pub(crate) struct InboundAuthorGate { agent_pubkey_hex: String, relay_self: Option, + // None means no authoritative NIP-11 result yet, including at startup. + refreshed_generation: Option, } - pub(crate) fn refresh_needed(refreshed_generation: u64, event_generation: u64) -> bool { - event_generation > refreshed_generation + pub(crate) fn refresh_needed(refreshed_generation: Option, event_generation: u64) -> bool { + refreshed_generation.is_none_or(|generation| event_generation > generation) } impl InboundAuthorGate { @@ -416,9 +418,27 @@ mod inbound_author_gate { agent_pubkey_hex: &str, context: &str, ) -> Self { + let (relay_self, completed) = refresh_relay_self(rest_client, None, context).await; Self { agent_pubkey_hex: agent_pubkey_hex.to_string(), - relay_self: refresh_relay_self(rest_client, None, context).await.0, + relay_self, + refreshed_generation: completed.then_some(0), + } + } + + /// Retry pending identity discovery before handling an event, including + /// generation 0 when the startup lookup failed. Only an authoritative + /// response completes a generation; failures remain eligible to retry. + pub(crate) async fn refresh_for_generation( + &mut self, + rest_client: &relay::RestClient, + event_generation: u64, + context: &str, + ) { + if refresh_needed(self.refreshed_generation, event_generation) + && self.refresh(rest_client, context).await + { + self.refreshed_generation = Some(event_generation); } } @@ -530,22 +550,6 @@ mod inbound_author_gate { } } -async fn refresh_author_gate_for_generation( - author_gate: &mut InboundAuthorGate, - rest_client: &relay::RestClient, - refreshed_generation: &mut u64, - event_generation: u64, - context: &str, -) { - if !inbound_author_gate::refresh_needed(*refreshed_generation, event_generation) { - return; - } - - if author_gate.refresh(rest_client, context).await { - *refreshed_generation = event_generation; - } -} - use inbound_author_gate::InboundAuthorGate; /// Refresh the relay signing identity, logging why delegated workflow @@ -2328,7 +2332,6 @@ async fn tokio_main() -> Result<()> { let relay_rest_client = relay.rest_client(); let mut author_gate_ctx = InboundAuthorGate::connect(&relay_rest_client, &pubkey_hex, "startup").await; - let mut refreshed_relay_generation = 0u64; relay .subscribe_membership_notifications() @@ -2941,14 +2944,13 @@ async fn tokio_main() -> Result<()> { let _ = result_rx; // end split borrow before relay handling match buzz_event { Some(buzz_event) => { - refresh_author_gate_for_generation( - &mut author_gate_ctx, - &relay_rest_client, - &mut refreshed_relay_generation, - buzz_event.connection_generation, - "reconnect", - ) - .await; + author_gate_ctx + .refresh_for_generation( + &relay_rest_client, + buzz_event.connection_generation, + "listener", + ) + .await; let kind_u32 = buzz_event.event.kind.as_u16() as u32; if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION @@ -6273,11 +6275,108 @@ mod author_gate_tests { } #[test] - fn listener_refreshes_exactly_once_per_connection_generation() { - assert!(!super::inbound_author_gate::refresh_needed(0, 0)); - assert!(super::inbound_author_gate::refresh_needed(0, 1)); - assert!(!super::inbound_author_gate::refresh_needed(1, 1)); - assert!(super::inbound_author_gate::refresh_needed(1, 2)); + fn refresh_needed_until_generation_completes() { + use super::inbound_author_gate::refresh_needed; + assert!(refresh_needed(None, 0)); + assert!(refresh_needed(None, 1)); + assert!(!refresh_needed(Some(0), 0)); + assert!(refresh_needed(Some(0), 1)); + assert!(!refresh_needed(Some(1), 1)); + assert!(!refresh_needed(Some(1), 0)); + assert!(refresh_needed(Some(1), 2)); + } + + #[tokio::test] + async fn test_generation_zero_retries_failed_startup_identity() { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + // Both startup probes fail; HTTP then recovers without a WS reconnect. + let (rest_client, server) = nip11_scripted_server(std::collections::VecDeque::from([ + Err(()), + Err(()), + Ok(serde_json::json!({ "self": relay_hex.clone() })), + ])) + .await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "startup").await; + assert!(!gate.has_relay_identity()); + let channel_id = Uuid::new_v4(); + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(relay_hex.clone(), false); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent), + }; + gate.refresh_for_generation(&rest_client, event.connection_generation, "startup retry") + .await; + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + server.abort(); + assert!( + decision.allowed, + "a generation-0 workflow wake must recover after the startup NIP-11 failure" + ); + assert_eq!(decision.effective_author, workflow_owner); + } + + #[tokio::test] + async fn test_authoritative_startup_result_completes_generation_zero() { + let relay_hex = nostr::Keys::generate().public_key().to_hex(); + let next_relay_hex = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + for identity in [Some(relay_hex.clone()), None] { + let document = match &identity { + Some(key) => serde_json::json!({ "self": key }), + None => serde_json::json!({ "name": "relay without stable identity" }), + }; + let mut responses = std::collections::VecDeque::from([Ok(document.clone())]); + if identity.is_none() { + // A missing `self` probes /info as well as the root. + responses.push_back(Ok(document)); + } + responses.push_back(Ok(serde_json::json!({ "self": next_relay_hex.clone() }))); + let (rest_client, server) = nip11_scripted_server(responses).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "startup").await; + assert_eq!(gate.relay_identity_for_test(), identity.as_deref()); + for _ in 0..2 { + gate.refresh_for_generation(&rest_client, 0, "same connection") + .await; + assert_eq!( + gate.relay_identity_for_test(), + identity.as_deref(), + "an authoritative startup response must not be fetched again at generation 0" + ); + } + gate.refresh_for_generation(&rest_client, 1, "reconnect") + .await; + assert_eq!( + gate.relay_identity_for_test(), + Some(next_relay_hex.as_str()), + "a later connection must still refresh after authoritative startup" + ); + server.abort(); + } } #[tokio::test] @@ -6311,7 +6410,6 @@ mod author_gate_tests { )]), rest_client.clone(), ); - let mut refreshed_generation = 1; assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); @@ -6320,18 +6418,8 @@ mod author_gate_tests { channel_id, event: relay_signed_workflow_dispatch(&new_relay, &workflow_owner, &agent), }; - refresh_author_gate_for_generation( - &mut gate, - &rest_client, - &mut refreshed_generation, - new_event.connection_generation, - "reconnect", - ) - .await; - assert_eq!( - refreshed_generation, 1, - "a failed NIP-11 refresh must leave the generation pending" - ); + gate.refresh_for_generation(&rest_client, new_event.connection_generation, "reconnect") + .await; assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); let first_new = gate .evaluate_listener_event( @@ -6348,15 +6436,12 @@ mod author_gate_tests { "the new signer must remain fail-closed while NIP-11 is unavailable" ); - refresh_author_gate_for_generation( - &mut gate, + gate.refresh_for_generation( &rest_client, - &mut refreshed_generation, new_event.connection_generation, "reconnect retry", ) .await; - assert_eq!(refreshed_generation, 2); assert_eq!(gate.relay_identity_for_test(), Some(new_relay_hex.as_str())); let stale_old_event = relay::BuzzEvent { @@ -6389,7 +6474,7 @@ mod author_gate_tests { assert_eq!(first_new.effective_author, workflow_owner); assert!( first_new.allowed, - "the first event from the new connection must use the refreshed relay key" + "a later event on the same connection must use the refreshed relay key" ); server.abort(); } diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 1caa07dcd21..c825b90798b 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -344,7 +344,6 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> let rest_client = relay.rest_client(); let mut author_gate_ctx = crate::InboundAuthorGate::connect(&rest_client, &pubkey_hex, "setup startup").await; - let mut refreshed_relay_generation = 0u64; // Resolve owner for author-gate (same priority as normal mode). let startup_owner = crate::resolve_agent_owner(&config); @@ -404,14 +403,13 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> continue; }; - crate::refresh_author_gate_for_generation( - &mut author_gate_ctx, - &rest_client, - &mut refreshed_relay_generation, - buzz_event.connection_generation, - "setup reconnect", - ) - .await; + author_gate_ctx + .refresh_for_generation( + &rest_client, + buzz_event.connection_generation, + "setup listener", + ) + .await; let kind_u32 = buzz_event.event.kind.as_u16() as u32; From 2cc972e24b5e88abb776bf480680326e200695cf Mon Sep 17 00:00:00 2001 From: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> Date: Fri, 28 Aug 2026 13:05:09 -0600 Subject: [PATCH 10/13] fix(acp): refresh relay identity inside author gate Make identity recovery part of evaluate_listener_event so normal and setup listeners cannot omit it independently of authorization. Remove the separate generation-refresh API and stream-end refreshes; refresh only when a delivered event reaches authorization. Drive startup, authoritative startup, and reconnect retry regressions through the production decision boundary without manual identity refresh. Signed-off-by: Pinky <5f5ab050ec58ae208332edd544ebf705221e24c1b86d82a6ca07038a7a8f6ac9@buzz.block.builderlab.xyz> --- crates/buzz-acp/src/lib.rs | 201 +++++++++++++++++------------- crates/buzz-acp/src/setup_mode.rs | 11 -- 2 files changed, 111 insertions(+), 101 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 650282746ee..29eac87bfbf 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -426,37 +426,6 @@ mod inbound_author_gate { } } - /// Retry pending identity discovery before handling an event, including - /// generation 0 when the startup lookup failed. Only an authoritative - /// response completes a generation; failures remain eligible to retry. - pub(crate) async fn refresh_for_generation( - &mut self, - rest_client: &relay::RestClient, - event_generation: u64, - context: &str, - ) { - if refresh_needed(self.refreshed_generation, event_generation) - && self.refresh(rest_client, context).await - { - self.refreshed_generation = Some(event_generation); - } - } - - /// Re-read the relay signing identity after a reconnect, retaining the - /// last verified key on a transient failure (see - /// [`refresh_relay_self`]). Returns whether NIP-11 produced an - /// authoritative document, including one without a `self` key. - pub(crate) async fn refresh( - &mut self, - rest_client: &relay::RestClient, - context: &str, - ) -> bool { - let (relay_self, completed) = - refresh_relay_self(rest_client, self.relay_self.take(), context).await; - self.relay_self = relay_self; - completed - } - /// Whether delegated workflow attribution is currently available. /// /// Test-only: production code never branches on this. @@ -473,14 +442,14 @@ mod inbound_author_gate { self.relay_self.as_deref() } - /// Resolve channel trust, trusted workflow attribution, and author policy - /// for one listener event. + /// Refresh relay identity, resolve channel trust, and apply trusted + /// workflow attribution and author policy for one listener event. /// - /// Both production listeners call this exact boundary. The raw-author - /// policy and relay identity are private to this module, so replacing a - /// listener call with raw-signer authorization is not expressible. + /// Both production listeners call this exact boundary. Identity refresh + /// cannot be omitted independently of authorization; the raw-author + /// policy and relay identity are private to this module. pub(crate) async fn evaluate_listener_event( - &self, + &mut self, buzz_event: &relay::BuzzEvent, respond_to: &RespondTo, allowlist: &HashSet, @@ -488,6 +457,17 @@ mod inbound_author_gate { channel_info: &pool::ChannelInfoResolver, rest_client: &relay::RestClient, ) -> InboundAuthorGateDecision { + // Retry failed startup discovery on generation 0 as well as failed + // reconnect refreshes. Only an authoritative result completes the + // generation; transient failure retains the last verified key. + if refresh_needed(self.refreshed_generation, buzz_event.connection_generation) { + let (relay_self, completed) = + refresh_relay_self(rest_client, self.relay_self.take(), "listener").await; + self.relay_self = relay_self; + if completed { + self.refreshed_generation = Some(buzz_event.connection_generation); + } + } let is_dm = is_dm_channel(buzz_event.channel_id, channel_info).await; self.evaluate_with_channel_trust( &buzz_event.event, @@ -557,8 +537,9 @@ use inbound_author_gate::InboundAuthorGate; /// key so a reconnect blip cannot disable workflow wakes. That availability /// tradeoff creates a bounded-by-success revocation window: a rotated-away key /// remains trusted while NIP-11 refreshes keep failing, then is replaced or -/// cleared by the next successful response. Refresh currently runs at startup -/// and reconnect, so rotation is not observed until a reconnect. +/// cleared by the next successful response. Refresh runs at startup and before +/// authorization on a new or still-pending generation; a completed generation +/// is not refreshed again until a reconnect. async fn refresh_relay_self( rest_client: &relay::RestClient, current: Option, @@ -2944,13 +2925,6 @@ async fn tokio_main() -> Result<()> { let _ = result_rx; // end split borrow before relay handling match buzz_event { Some(buzz_event) => { - author_gate_ctx - .refresh_for_generation( - &relay_rest_client, - buzz_event.connection_generation, - "listener", - ) - .await; let kind_u32 = buzz_event.event.kind.as_u16() as u32; if kind_u32 == KIND_MEMBER_ADDED_NOTIFICATION @@ -3312,9 +3286,6 @@ async fn tokio_main() -> Result<()> { tokio::time::sleep(Duration::from_secs(1)).await; break; } - author_gate_ctx - .refresh(&relay_rest_client, "reconnect") - .await; } } None @@ -6118,8 +6089,8 @@ mod author_gate_tests { /// The listener decision-boundary regression. /// - /// Both listeners call `evaluate_listener_event`; it owns channel trust, - /// workflow attribution, and raw-author policy, with no production-visible + /// Both listeners call `evaluate_listener_event`; it owns identity refresh, + /// channel trust, workflow attribution, and policy, with no production-visible /// raw-policy helper alongside it. This test drives that exact callable /// against a live NIP-11 document, so it fails if identity loading, /// effective-author resolution, DM classification, or policy application @@ -6134,7 +6105,7 @@ mod author_gate_tests { let agent = nostr::Keys::generate().public_key().to_hex(); let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; - let gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "test").await; assert!( gate.has_relay_identity(), "the gate must load the relay signing identity during construction" @@ -6229,9 +6200,9 @@ mod author_gate_tests { server.abort(); } - /// A reconnect must re-arm attribution rather than leaving the listener - /// permanently degraded: `refresh` is the only path that updates the - /// identity after construction, and both listeners call it on reconnect. + /// The first authorized event after reconnect must restore attribution + /// through the same decision boundary both listeners use, without a + /// separate identity-refresh call. #[tokio::test] async fn test_gate_refresh_arms_attribution_after_reconnect() { let relay_keys = nostr::Keys::generate(); @@ -6249,20 +6220,36 @@ mod author_gate_tests { assert!(!gate.has_relay_identity()); let (rest_client, server) = nip11_server(serde_json::json!({ "self": relay_hex })).await; - gate.refresh(&rest_client, "test reconnect").await; - let workflow_owner = nostr::Keys::generate().public_key().to_hex(); let event = relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent); let cache = cache_with_sibling(); cache.cache_sibling(workflow_owner.clone(), true); + cache.cache_sibling(relay_hex, false); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let buzz_event = relay::BuzzEvent { + connection_generation: 1, + channel_id, + event, + }; let decision = gate - .evaluate_for_test( - &event, + .evaluate_listener_event( + &buzz_event, &RespondTo::OwnerOnly, &HashSet::new(), - false, &cache, + &channel_info, &rest_client, ) .await; @@ -6320,8 +6307,6 @@ mod author_gate_tests { channel_id, event: relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent), }; - gate.refresh_for_generation(&rest_client, event.connection_generation, "startup retry") - .await; let decision = gate .evaluate_listener_event( &event, @@ -6342,9 +6327,15 @@ mod author_gate_tests { #[tokio::test] async fn test_authoritative_startup_result_completes_generation_zero() { - let relay_hex = nostr::Keys::generate().public_key().to_hex(); - let next_relay_hex = nostr::Keys::generate().public_key().to_hex(); + let relay_keys = nostr::Keys::generate(); + let next_relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let next_relay_hex = next_relay_keys.public_key().to_hex(); let agent = nostr::Keys::generate().public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + owner_cache.cache_sibling(relay_hex.clone(), false); + owner_cache.cache_sibling(next_relay_hex.clone(), false); for identity in [Some(relay_hex.clone()), None] { let document = match &identity { Some(key) => serde_json::json!({ "self": key }), @@ -6359,17 +6350,55 @@ mod author_gate_tests { let (rest_client, server) = nip11_scripted_server(responses).await; let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "startup").await; assert_eq!(gate.relay_identity_for_test(), identity.as_deref()); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let mut event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: relay_signed_workflow_dispatch(&relay_keys, &workflow_owner, &agent), + }; for _ in 0..2 { - gate.refresh_for_generation(&rest_client, 0, "same connection") + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) .await; + assert_eq!(decision.allowed, identity.is_some()); assert_eq!( gate.relay_identity_for_test(), identity.as_deref(), "an authoritative startup response must not be fetched again at generation 0" ); } - gate.refresh_for_generation(&rest_client, 1, "reconnect") + event.connection_generation = 1; + event.event = relay_signed_workflow_dispatch(&next_relay_keys, &workflow_owner, &agent); + let decision = gate + .evaluate_listener_event( + &event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) .await; + assert!(decision.allowed); + assert_eq!(decision.effective_author, workflow_owner); assert_eq!( gate.relay_identity_for_test(), Some(next_relay_hex.as_str()), @@ -6418,9 +6447,6 @@ mod author_gate_tests { channel_id, event: relay_signed_workflow_dispatch(&new_relay, &workflow_owner, &agent), }; - gate.refresh_for_generation(&rest_client, new_event.connection_generation, "reconnect") - .await; - assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); let first_new = gate .evaluate_listener_event( &new_event, @@ -6431,18 +6457,28 @@ mod author_gate_tests { &rest_client, ) .await; + assert_eq!(gate.relay_identity_for_test(), Some(old_relay_hex.as_str())); assert!( !first_new.allowed, "the new signer must remain fail-closed while NIP-11 is unavailable" ); - gate.refresh_for_generation( - &rest_client, - new_event.connection_generation, - "reconnect retry", - ) - .await; + let recovered = gate + .evaluate_listener_event( + &new_event, + &RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await; assert_eq!(gate.relay_identity_for_test(), Some(new_relay_hex.as_str())); + assert_eq!(recovered.effective_author, workflow_owner); + assert!( + recovered.allowed, + "a later event on the same connection must use the refreshed relay key" + ); let stale_old_event = relay::BuzzEvent { connection_generation: 2, @@ -6461,21 +6497,6 @@ mod author_gate_tests { .await; assert!(!stale.allowed, "the rotated-away relay key must be evicted"); - let first_new = gate - .evaluate_listener_event( - &new_event, - &RespondTo::OwnerOnly, - &HashSet::new(), - &owner_cache, - &channel_info, - &rest_client, - ) - .await; - assert_eq!(first_new.effective_author, workflow_owner); - assert!( - first_new.allowed, - "a later event on the same connection must use the refreshed relay key" - ); server.abort(); } diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index c825b90798b..593cd6bcf04 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -397,20 +397,9 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> tracing::error!("setup-mode: relay background task is gone: {e} — exiting"); break; } - author_gate_ctx - .refresh(&rest_client, "setup reconnect") - .await; continue; }; - author_gate_ctx - .refresh_for_generation( - &rest_client, - buzz_event.connection_generation, - "setup listener", - ) - .await; - let kind_u32 = buzz_event.event.kind.as_u16() as u32; // Handle membership notifications so we subscribe to new channels From 7616bd7fb1848baaaec7f047049867029aa39af2 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 13:54:21 -0600 Subject: [PATCH 11/13] test(acp): cover production listener author gates Extract each production listener author boundary into the callable used by its loop, then drive both callables through trusted workflow attribution, configured denial, and generation-zero relay identity recovery. Both compiling bypass mutations now fail deterministically instead of leaving the ACP suite green. Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-acp/src/lib.rs | 242 +++++++++++++++++++++++++++--- crates/buzz-acp/src/setup_mode.rs | 46 ++++-- 2 files changed, 255 insertions(+), 33 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 29eac87bfbf..277c5991edd 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -532,6 +532,44 @@ mod inbound_author_gate { use inbound_author_gate::InboundAuthorGate; +/// Apply the complete normal-listener author boundary for one relay event. +/// +/// Keeping this as the production callable used by the loop lets regression +/// tests prove workflow attribution, identity recovery, and policy denial at +/// the listener seam rather than only inside [`InboundAuthorGate`]. +async fn evaluate_normal_listener_author( + author_gate: &mut InboundAuthorGate, + buzz_event: &relay::BuzzEvent, + respond_to: &RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, + rest_client: &relay::RestClient, +) -> Option { + let decision = author_gate + .evaluate_listener_event( + buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await; + if !decision.allowed { + tracing::debug!( + channel_id = %buzz_event.channel_id, + raw_author = %buzz_event.event.pubkey.to_hex(), + effective_author = %decision.effective_author, + mode = %respond_to, + is_dm = decision.is_dm, + "inbound author gate — dropping event" + ); + return None; + } + Some(decision.effective_author) +} + /// Refresh the relay signing identity, logging why delegated workflow /// attribution is unavailable. A transient fetch error keeps the last verified /// key so a reconnect blip cannot disable workflow wakes. That availability @@ -3162,28 +3200,19 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. - let author_gate = author_gate_ctx - .evaluate_listener_event( - &buzz_event, - &config.respond_to, - &config.respond_to_allowlist, - &owner_cache, - &ctx.channel_info, - &ctx.rest_client, - ) - .await; - let author_hex = author_gate.effective_author; - if !author_gate.allowed { - tracing::debug!( - channel_id = %buzz_event.channel_id, - raw_author = %buzz_event.event.pubkey.to_hex(), - effective_author = %author_hex, - mode = %config.respond_to, - is_dm = author_gate.is_dm, - "inbound author gate — dropping event" - ); + let Some(author_hex) = evaluate_normal_listener_author( + &mut author_gate_ctx, + &buzz_event, + &config.respond_to, + &config.respond_to_allowlist, + &owner_cache, + &ctx.channel_info, + &ctx.rest_client, + ) + .await + else { continue; - } + }; let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; let prompt_tag = match matched { @@ -6087,6 +6116,177 @@ mod author_gate_tests { .expect("signed workflow event") } + async fn listener_boundary_scenario( + listener: ListenerBoundary, + relay_keys: &nostr::Keys, + workflow_owner: &str, + responses: std::collections::VecDeque>, + event_generation: u64, + respond_to: RespondTo, + cache_owner: bool, + ) -> (Option, bool) { + let relay_hex = relay_keys.public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let (rest_client, server) = nip11_scripted_server(responses).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "listener startup").await; + let owner_cache = OwnerCache::new(cache_owner.then(|| workflow_owner.to_string())); + owner_cache.cache_sibling(relay_hex, false); + let channel_id = Uuid::new_v4(); + let channel_info = pool::ChannelInfoResolver::new( + HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let event = relay::BuzzEvent { + connection_generation: event_generation, + channel_id, + event: relay_signed_workflow_dispatch(relay_keys, workflow_owner, &agent), + }; + let allowlist = HashSet::new(); + let (effective_author, allowed) = match listener { + ListenerBoundary::Normal => { + let author = evaluate_normal_listener_author( + &mut gate, + &event, + &respond_to, + &allowlist, + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + let allowed = author.is_some(); + (author, allowed) + } + ListenerBoundary::Setup => { + let decision = setup_mode::evaluate_setup_listener_author( + &mut gate, + &event, + &respond_to, + &allowlist, + &owner_cache, + &channel_info, + &rest_client, + ) + .await; + (Some(decision.effective_author), decision.allowed) + } + }; + server.abort(); + (effective_author, allowed) + } + + #[derive(Clone, Copy, Debug)] + enum ListenerBoundary { + Normal, + Setup, + } + + impl ListenerBoundary { + fn name(self) -> &'static str { + match self { + Self::Normal => "normal", + Self::Setup => "setup", + } + } + } + + /// Both production listener callables must attribute relay-signed workflow + /// events to the workflow owner and enforce policy there. A local + /// `allowed: true` replacement at either call site makes the Nobody case + /// fail; using the raw relay signer makes the OwnerOnly case fail. + #[tokio::test] + async fn production_listener_boundaries_apply_workflow_owner_policy() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let accepted_workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let accepted = listener_boundary_scenario( + listener, + &relay_keys, + &accepted_workflow_owner, + std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), + 0, + RespondTo::OwnerOnly, + true, + ) + .await; + assert!( + accepted.1, + "{} listener must allow the workflow owner", + listener.name() + ); + assert_eq!( + accepted.0.as_deref(), + Some(accepted_workflow_owner.as_str()), + "{} listener must preserve the effective workflow owner", + listener.name() + ); + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let denied_workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let denied = listener_boundary_scenario( + listener, + &relay_keys, + &denied_workflow_owner, + std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), + 0, + RespondTo::Nobody, + true, + ) + .await; + assert!( + !denied.1, + "{} listener must enforce respond-to=nobody", + listener.name() + ); + } + } + + /// Both production boundaries must perform the pending generation-zero + /// refresh before policy evaluation. Bypassing the gate invocation leaves + /// the relay signer denied and makes this recovery assertion fail. + #[tokio::test] + async fn production_listener_boundaries_recover_relay_identity() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let result = listener_boundary_scenario( + listener, + &relay_keys, + &workflow_owner, + std::collections::VecDeque::from([ + Err(()), + Err(()), + Ok(serde_json::json!({ "self": relay_hex })), + ]), + 0, + RespondTo::OwnerOnly, + true, + ) + .await; + assert!( + result.1, + "{} listener must recover identity before authorization", + listener.name() + ); + assert_eq!( + result.0.as_deref(), + Some(workflow_owner.as_str()), + "{} listener must preserve the recovered workflow owner", + listener.name() + ); + } + } + /// The listener decision-boundary regression. /// /// Both listeners call `evaluate_listener_event`; it owns identity refresh, diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 593cd6bcf04..d34a167bddc 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -73,7 +73,8 @@ pub(crate) enum AcpAvailabilityStatus { use crate::{ config::Config, event_mentions_agent, filter, - relay::{HarnessRelay, RelayEventPublisher}, + relay::{self, HarnessRelay, RelayEventPublisher}, + InboundAuthorGate, InboundAuthorGateDecision, OwnerCache, }; // ── Payload ─────────────────────────────────────────────────────────────────── @@ -430,17 +431,17 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let author_gate = author_gate_ctx - .evaluate_listener_event( - &buzz_event, - &config.respond_to, - &config.respond_to_allowlist, - &owner_cache, - &channel_info, - &rest_client, - ) - .await; - let allowed = author_gate.allowed; + let allowed = evaluate_setup_listener_author( + &mut author_gate_ctx, + &buzz_event, + &config.respond_to, + &config.respond_to_allowlist, + &owner_cache, + &channel_info, + &rest_client, + ) + .await + .allowed; // Apply channel/kind filter rules. let filter_matched = filter::match_event( @@ -485,6 +486,27 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> Ok(()) } +pub(super) async fn evaluate_setup_listener_author( + author_gate: &mut InboundAuthorGate, + buzz_event: &relay::BuzzEvent, + respond_to: &crate::config::RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &crate::pool::ChannelInfoResolver, + rest_client: &relay::RestClient, +) -> InboundAuthorGateDecision { + author_gate + .evaluate_listener_event( + buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await +} + /// Outcome of the pure per-event gate checks in setup mode. /// /// Callers compute the async gates (`author_allowed`, `filter::match_event`) From 9f19e893cf3c61937cff014a1686b3e3d8e1b5af Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 28 Aug 2026 15:47:48 -0600 Subject: [PATCH 12/13] fix(acp): require authorized listener events Move inbound events into a private authorization capability before either production listener can queue or publish them. Protect both loop edges structurally and cover owner, sibling, external, nobody, and anyone policy in DMs. Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-acp/src/lib.rs | 325 +++++++++++++++++++++++++----- crates/buzz-acp/src/setup_mode.rs | 141 ++++++------- 2 files changed, 346 insertions(+), 120 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 277c5991edd..baeb2d88140 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -317,17 +317,6 @@ fn effective_prompt_author( .unwrap_or_else(|| event.pubkey.to_hex()) } -/// Combined event-to-author gate used by both the normal and setup listeners. -/// -/// Keeping workflow attribution and the existing author policy in one helper -/// prevents either runtime path from accidentally reverting to the raw relay -/// signer while unit tests continue to exercise only the individual pieces. -struct InboundAuthorGateDecision { - effective_author: String, - allowed: bool, - is_dm: bool, -} - /// Owns the verified relay signing identity for a listener's lifetime and /// applies the inbound author gate to each event. /// @@ -347,10 +336,38 @@ struct InboundAuthorGateDecision { mod inbound_author_gate { use super::{ effective_prompt_author, is_dm_channel, is_owner_or_sibling, pool, refresh_relay_self, - relay, InboundAuthorGateDecision, OwnerCache, RespondTo, + relay, OwnerCache, RespondTo, }; use std::collections::HashSet; + pub(crate) struct InboundAuthorGateDecision { + pub(crate) effective_author: String, + pub(crate) allowed: bool, + pub(crate) is_dm: bool, + } + + /// An event that passed the complete listener author boundary. + /// + /// The event is moved into the gate before policy evaluation and can only + /// be recovered through this private-field capability. Both production + /// loops therefore have to consume the gate's verdict before they can use + /// or publish the event; replacing the call with a raw signer or a local + /// `allowed = true` no longer type-checks. + pub(crate) struct AuthorizedListenerEvent { + buzz_event: relay::BuzzEvent, + effective_author: String, + } + + impl AuthorizedListenerEvent { + pub(crate) fn into_parts(self) -> (relay::BuzzEvent, String) { + (self.buzz_event, self.effective_author) + } + + pub(crate) fn into_event(self) -> relay::BuzzEvent { + self.buzz_event + } + } + /// Apply the configured raw-author policy after trusted workflow attribution. /// /// This stays private to the gate module so neither listener can bypass @@ -507,6 +524,42 @@ mod inbound_author_gate { } } + pub(crate) async fn authorize_listener_event( + &mut self, + buzz_event: relay::BuzzEvent, + respond_to: &RespondTo, + allowlist: &HashSet, + owner_cache: &OwnerCache, + channel_info: &pool::ChannelInfoResolver, + rest_client: &relay::RestClient, + ) -> Option { + let decision = self + .evaluate_listener_event( + &buzz_event, + respond_to, + allowlist, + owner_cache, + channel_info, + rest_client, + ) + .await; + if !decision.allowed { + tracing::debug!( + channel_id = %buzz_event.channel_id, + raw_author = %buzz_event.event.pubkey.to_hex(), + effective_author = %decision.effective_author, + mode = %respond_to, + is_dm = decision.is_dm, + "inbound author gate — dropping event" + ); + return None; + } + Some(AuthorizedListenerEvent { + buzz_event, + effective_author: decision.effective_author, + }) + } + #[cfg(test)] pub(crate) async fn evaluate_for_test( &self, @@ -530,24 +583,31 @@ mod inbound_author_gate { } } -use inbound_author_gate::InboundAuthorGate; +use inbound_author_gate::{AuthorizedListenerEvent, InboundAuthorGate}; + +struct AuthorizedNormalListenerEvent(AuthorizedListenerEvent); + +impl AuthorizedNormalListenerEvent { + fn into_parts(self) -> (relay::BuzzEvent, String) { + self.0.into_parts() + } +} /// Apply the complete normal-listener author boundary for one relay event. /// -/// Keeping this as the production callable used by the loop lets regression -/// tests prove workflow attribution, identity recovery, and policy denial at -/// the listener seam rather than only inside [`InboundAuthorGate`]. -async fn evaluate_normal_listener_author( +/// The event is consumed here, so the production loop cannot recover it except +/// from the gate's private authorized capability. +async fn authorize_normal_listener_event( author_gate: &mut InboundAuthorGate, - buzz_event: &relay::BuzzEvent, + buzz_event: relay::BuzzEvent, respond_to: &RespondTo, allowlist: &HashSet, owner_cache: &OwnerCache, channel_info: &pool::ChannelInfoResolver, rest_client: &relay::RestClient, -) -> Option { - let decision = author_gate - .evaluate_listener_event( +) -> Option { + author_gate + .authorize_listener_event( buzz_event, respond_to, allowlist, @@ -555,19 +615,7 @@ async fn evaluate_normal_listener_author( channel_info, rest_client, ) - .await; - if !decision.allowed { - tracing::debug!( - channel_id = %buzz_event.channel_id, - raw_author = %buzz_event.event.pubkey.to_hex(), - effective_author = %decision.effective_author, - mode = %respond_to, - is_dm = decision.is_dm, - "inbound author gate — dropping event" - ); - return None; - } - Some(decision.effective_author) + .await } /// Refresh the relay signing identity, logging why delegated workflow @@ -3200,9 +3248,10 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. - let Some(author_hex) = evaluate_normal_listener_author( + // LISTENER_AUTHOR_GATE_BEGIN + let Some(authorized_event) = authorize_normal_listener_event( &mut author_gate_ctx, - &buzz_event, + buzz_event, &config.respond_to, &config.respond_to_allowlist, &owner_cache, @@ -3213,6 +3262,9 @@ async fn tokio_main() -> Result<()> { else { continue; }; + let (buzz_event, author_hex) = + AuthorizedNormalListenerEvent(authorized_event).into_parts(); + // LISTENER_AUTHOR_GATE_END let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; let prompt_tag = match matched { @@ -6122,22 +6174,33 @@ mod author_gate_tests { workflow_owner: &str, responses: std::collections::VecDeque>, event_generation: u64, + channel_type: &str, respond_to: RespondTo, + allowlist: HashSet, cache_owner: bool, + cache_sibling: bool, ) -> (Option, bool) { let relay_hex = relay_keys.public_key().to_hex(); let agent = nostr::Keys::generate().public_key().to_hex(); let (rest_client, server) = nip11_scripted_server(responses).await; let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "listener startup").await; - let owner_cache = OwnerCache::new(cache_owner.then(|| workflow_owner.to_string())); + let configured_owner = if cache_owner { + Some(workflow_owner.to_string()) + } else if cache_sibling { + Some(nostr::Keys::generate().public_key().to_hex()) + } else { + None + }; + let owner_cache = OwnerCache::new(configured_owner); owner_cache.cache_sibling(relay_hex, false); + owner_cache.cache_sibling(workflow_owner.to_string(), cache_sibling); let channel_id = Uuid::new_v4(); let channel_info = pool::ChannelInfoResolver::new( HashMap::from([( channel_id, relay::ChannelInfo { name: "workflow".into(), - channel_type: "stream".into(), + channel_type: channel_type.into(), description: None, }, )]), @@ -6148,38 +6211,36 @@ mod author_gate_tests { channel_id, event: relay_signed_workflow_dispatch(relay_keys, workflow_owner, &agent), }; - let allowlist = HashSet::new(); - let (effective_author, allowed) = match listener { + let authorized = match listener { ListenerBoundary::Normal => { - let author = evaluate_normal_listener_author( + authorize_normal_listener_event( &mut gate, - &event, + event, &respond_to, &allowlist, &owner_cache, &channel_info, &rest_client, ) - .await; - let allowed = author.is_some(); - (author, allowed) + .await } ListenerBoundary::Setup => { - let decision = setup_mode::evaluate_setup_listener_author( + setup_mode::authorize_setup_listener_event( &mut gate, - &event, + event, &respond_to, &allowlist, &owner_cache, &channel_info, &rest_client, ) - .await; - (Some(decision.effective_author), decision.allowed) + .await } }; + let result = authorized.map(|event| event.into_parts().1); server.abort(); - (effective_author, allowed) + let allowed = result.is_some(); + (result, allowed) } #[derive(Clone, Copy, Debug)] @@ -6197,6 +6258,47 @@ mod author_gate_tests { } } + fn listener_gate_region<'a>(source: &'a str, label: &str) -> &'a str { + let begin = ["LISTENER_AUTHOR_GATE_", "BEGIN"].concat(); + let end = ["LISTENER_AUTHOR_GATE_", "END"].concat(); + let mut regions = source.split(&begin); + let _before = regions.next().expect("source has a prefix"); + let region = regions + .next() + .unwrap_or_else(|| panic!("{label} listener gate begin marker missing")); + assert!( + regions.next().is_none(), + "{label} listener gate begin marker must be unique" + ); + let mut region_parts = region.split(&end); + let body = region_parts + .next() + .expect("gate region has a body before its end marker"); + assert!( + region_parts.next().is_some(), + "{label} listener gate end marker missing" + ); + body + } + + /// The production loop segments themselves retain the authorization + /// capability edge. This complements behavioral tests at the callables: + /// replacing either loop call with a raw signer or permissive boolean + /// removes the required symbols from its marked segment and fails here. + #[test] + fn production_listener_loops_consume_authorized_event_capabilities() { + let normal = listener_gate_region(include_str!("lib.rs"), "normal"); + assert_eq!( + normal.matches("authorize_normal_listener_event(").count(), + 1 + ); + assert_eq!(normal.matches("AuthorizedNormalListenerEvent(").count(), 1); + + let setup = listener_gate_region(include_str!("setup_mode.rs"), "setup"); + assert_eq!(setup.matches("authorize_setup_listener_event(").count(), 1); + assert_eq!(setup.matches("nudge_authorized_event(").count(), 1); + } + /// Both production listener callables must attribute relay-signed workflow /// events to the workflow owner and enforce policy there. A local /// `allowed: true` replacement at either call site makes the Nobody case @@ -6213,8 +6315,11 @@ mod author_gate_tests { &accepted_workflow_owner, std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), 0, + "stream", RespondTo::OwnerOnly, + HashSet::new(), true, + false, ) .await; assert!( @@ -6238,8 +6343,11 @@ mod author_gate_tests { &denied_workflow_owner, std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), 0, + "stream", RespondTo::Nobody, + HashSet::new(), true, + false, ) .await; assert!( @@ -6250,6 +6358,120 @@ mod author_gate_tests { } } + /// Both production boundaries must retain DM classification when composing + /// trusted workflow attribution with configured author policy. External + /// allowlist entries and `Anyone` stay denied in a DM; owner and sibling + /// principals remain allowed; `Nobody` remains absolute. + #[tokio::test] + async fn production_listener_boundaries_enforce_dm_author_policy() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let external = nostr::Keys::generate().public_key().to_hex(); + let external_allowlist = HashSet::from([external.clone()]); + let denied_external = listener_boundary_scenario( + listener, + &relay_keys, + &external, + std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), + 0, + "dm", + RespondTo::Allowlist, + external_allowlist, + false, + false, + ) + .await; + assert!( + !denied_external.1, + "{} listener must deny an external allowlist entry in a DM", + listener.name() + ); + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let stranger = nostr::Keys::generate().public_key().to_hex(); + let denied_stranger = listener_boundary_scenario( + listener, + &relay_keys, + &stranger, + std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), + 0, + "dm", + RespondTo::Anyone, + HashSet::new(), + false, + false, + ) + .await; + assert!( + !denied_stranger.1, + "{} listener must deny a stranger in a DM under Anyone", + listener.name() + ); + + for (principal, cache_owner, cache_sibling, label) in [ + ( + nostr::Keys::generate().public_key().to_hex(), + true, + false, + "owner", + ), + ( + nostr::Keys::generate().public_key().to_hex(), + false, + true, + "sibling", + ), + ] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let allowed = listener_boundary_scenario( + listener, + &relay_keys, + &principal, + std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + 0, + "dm", + RespondTo::Anyone, + HashSet::new(), + cache_owner, + cache_sibling, + ) + .await; + assert!( + allowed.1, + "{} listener must allow the {label} in a DM", + listener.name() + ); + } + + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let owner = nostr::Keys::generate().public_key().to_hex(); + let denied_nobody = listener_boundary_scenario( + listener, + &relay_keys, + &owner, + std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), + 0, + "dm", + RespondTo::Nobody, + HashSet::new(), + true, + false, + ) + .await; + assert!( + !denied_nobody.1, + "{} listener must enforce Nobody in a DM", + listener.name() + ); + } + } + /// Both production boundaries must perform the pending generation-zero /// refresh before policy evaluation. Bypassing the gate invocation leaves /// the relay signer denied and makes this recovery assertion fail. @@ -6269,8 +6491,11 @@ mod author_gate_tests { Ok(serde_json::json!({ "self": relay_hex })), ]), 0, + "stream", RespondTo::OwnerOnly, + HashSet::new(), true, + false, ) .await; assert!( diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index d34a167bddc..6b3f08486d4 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -73,8 +73,9 @@ pub(crate) enum AcpAvailabilityStatus { use crate::{ config::Config, event_mentions_agent, filter, + inbound_author_gate::AuthorizedListenerEvent, relay::{self, HarnessRelay, RelayEventPublisher}, - InboundAuthorGate, InboundAuthorGateDecision, OwnerCache, + InboundAuthorGate, OwnerCache, }; // ── Payload ─────────────────────────────────────────────────────────────────── @@ -431,9 +432,10 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - let allowed = evaluate_setup_listener_author( + // LISTENER_AUTHOR_GATE_BEGIN + let Some(authorized_event) = authorize_setup_listener_event( &mut author_gate_ctx, - &buzz_event, + buzz_event, &config.respond_to, &config.respond_to_allowlist, &owner_cache, @@ -441,62 +443,82 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> &rest_client, ) .await - .allowed; + else { + continue; + }; - // Apply channel/kind filter rules. - let filter_matched = filter::match_event( - &buzz_event.event, - buzz_event.channel_id, + if !nudge_authorized_event( + authorized_event, &rules, &pubkey_hex, - ) - .await - .is_some(); - - // Pure gate: author gate verdict + event-id dedup. - if !should_nudge_for_event( - buzz_event.event.id, - allowed, - filter_matched, &mut nudged_event_ids, - ) { - continue; - } - - // Build and publish the setup nudge. - if let Err(e) = publish_setup_nudge( &publisher, &config.keys, - buzz_event.channel_id, - &buzz_event.event, &payload, ) .await { - tracing::warn!("setup-mode: failed to publish nudge: {e}"); - } else { - tracing::info!( - channel_id = %buzz_event.channel_id, - event_id = %buzz_event.event.id, - "setup-mode: nudge published" - ); + continue; } + // LISTENER_AUTHOR_GATE_END } Ok(()) } -pub(super) async fn evaluate_setup_listener_author( +async fn nudge_authorized_event( + authorized_event: AuthorizedListenerEvent, + rules: &[filter::SubscriptionRule], + pubkey_hex: &str, + nudged_event_ids: &mut HashSet, + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + payload: &SetupPayload, +) -> bool { + let buzz_event = authorized_event.into_event(); + + // Apply channel/kind filter rules. + let filter_matched = + filter::match_event(&buzz_event.event, buzz_event.channel_id, rules, pubkey_hex) + .await + .is_some(); + + if !should_nudge_for_event(buzz_event.event.id, filter_matched, nudged_event_ids) { + return false; + } + + // Build and publish the setup nudge. + if let Err(e) = publish_setup_nudge( + publisher, + keys, + buzz_event.channel_id, + &buzz_event.event, + payload, + ) + .await + { + tracing::warn!("setup-mode: failed to publish nudge: {e}"); + } else { + tracing::info!( + channel_id = %buzz_event.channel_id, + event_id = %buzz_event.event.id, + "setup-mode: nudge published" + ); + } + true +} + +pub(super) async fn authorize_setup_listener_event( author_gate: &mut InboundAuthorGate, - buzz_event: &relay::BuzzEvent, + buzz_event: relay::BuzzEvent, respond_to: &crate::config::RespondTo, allowlist: &HashSet, owner_cache: &OwnerCache, channel_info: &crate::pool::ChannelInfoResolver, rest_client: &relay::RestClient, -) -> InboundAuthorGateDecision { +) -> Option { author_gate - .evaluate_listener_event( + .authorize_listener_event( buzz_event, respond_to, allowlist, @@ -507,25 +529,19 @@ pub(super) async fn evaluate_setup_listener_author( .await } -/// Outcome of the pure per-event gate checks in setup mode. +/// Outcome of the synchronous per-event setup checks. /// -/// Callers compute the async gates (`author_allowed`, `filter::match_event`) -/// up-front, then pass the boolean results here. This helper handles -/// everything that is synchronous and stateful: the author gate verdict -/// and event-id dedup. +/// This helper owns only filter matching and event-id deduplication; the +/// production path can call it only through `nudge_authorized_event`, whose +/// input is the gate's private authorized capability. /// /// Returns `true` when the event should produce a nudge. #[must_use] pub(crate) fn should_nudge_for_event( event_id: EventId, - author_allowed: bool, filter_matched: bool, nudged_event_ids: &mut HashSet, ) -> bool { - if !author_allowed { - tracing::debug!("setup-mode: event filtered by author gate"); - return false; - } if !filter_matched { return false; } @@ -1012,32 +1028,25 @@ mod tests { // ── should_nudge_for_event gate tests ───────────────────────────────────── // - // These tests exercise the loop-wiring for the two safety-critical guards: - // (a) non-allowlisted author → no nudge, (b) same event-id → exactly one - // nudge. They use the extracted `should_nudge_for_event` helper, which is - // the exact code the live loop calls. + // These tests exercise the loop-adjacent synchronous guards after an event + // has passed the structurally mandatory author capability: (a) unmatched + // filter → no nudge, (b) same event-id → exactly one nudge. fn fake_event_id(byte: u8) -> EventId { EventId::from_byte_array([byte; 32]) } #[test] - fn test_non_allowlisted_author_returns_no_nudge() { - // author_allowed = false → should return false regardless of other args. + fn test_unmatched_filter_returns_no_nudge() { let mut dedup: HashSet = HashSet::new(); let event_id = fake_event_id(0xAA); - let result = should_nudge_for_event( - event_id, false, // author NOT allowed - true, // filter matched — would otherwise nudge - &mut dedup, - ); + let result = should_nudge_for_event(event_id, false, &mut dedup); - assert!(!result, "non-allowlisted author must not produce a nudge"); - // Dedup set must remain empty — no phantom insertion for blocked author. + assert!(!result, "unmatched event must not produce a nudge"); assert!( dedup.is_empty(), - "dedup set must not record event for blocked author" + "dedup set must not record an unmatched event" ); } @@ -1048,19 +1057,11 @@ mod tests { let mut dedup: HashSet = HashSet::new(); let event_id = fake_event_id(0xBB); - let first = should_nudge_for_event( - event_id, true, // allowed - true, // matched - &mut dedup, - ); + let first = should_nudge_for_event(event_id, true, &mut dedup); assert!(first, "first occurrence must be accepted"); // Simulate reconnect replay: same event arrives again. - let second = should_nudge_for_event( - event_id, true, // allowed - true, // matched - &mut dedup, - ); + let second = should_nudge_for_event(event_id, true, &mut dedup); assert!( !second, "replay of the same event-id must be rejected (dedup)" From 22ba7454a603f1e47950737b37a03ee38abc6da7 Mon Sep 17 00:00:00 2001 From: Wes Date: Sat, 29 Aug 2026 08:28:57 -0600 Subject: [PATCH 13/13] fix(acp): preserve authorized listener provenance Keep normal listener filtering, queueing, reactions, and steer handling behind authorized ingress types. Address setup nudges to the verified workflow owner and replace brittle source scanning with behavioral coverage. Co-authored-by: Carl <9d00794d3df50972eb8b615511783cab12a77a8fd5dd5edd58073ec73b54bd8b@buzz.block.builderlab.xyz> Signed-off-by: Wes --- crates/buzz-acp/src/lib.rs | 442 ++++++++++++++++-------------- crates/buzz-acp/src/setup_mode.rs | 94 ++++++- 2 files changed, 317 insertions(+), 219 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index baeb2d88140..2de474ac1e2 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -362,10 +362,6 @@ mod inbound_author_gate { pub(crate) fn into_parts(self) -> (relay::BuzzEvent, String) { (self.buzz_event, self.effective_author) } - - pub(crate) fn into_event(self) -> relay::BuzzEvent { - self.buzz_event - } } /// Apply the configured raw-author policy after trusted workflow attribution. @@ -587,9 +583,109 @@ use inbound_author_gate::{AuthorizedListenerEvent, InboundAuthorGate}; struct AuthorizedNormalListenerEvent(AuthorizedListenerEvent); +struct NormalListenerIngress { + buzz_event: relay::BuzzEvent, + effective_author: String, + prompt_tag: String, +} + impl AuthorizedNormalListenerEvent { - fn into_parts(self) -> (relay::BuzzEvent, String) { - self.0.into_parts() + async fn match_subscription( + self, + rules: &[SubscriptionRule], + agent_pubkey_hex: &str, + ) -> Option { + let (buzz_event, effective_author) = self.0.into_parts(); + let matched = filter::match_event( + &buzz_event.event, + buzz_event.channel_id, + rules, + agent_pubkey_hex, + ) + .await?; + Some(NormalListenerIngress { + buzz_event, + effective_author, + prompt_tag: matched.prompt_tag, + }) + } +} + +struct QueuedNormalListenerEvent { + accepted: bool, + channel_id: Uuid, + effective_author: String, + event_id_hex: String, + event_for_steer: nostr::Event, + prompt_tag_for_steer: String, +} + +impl QueuedNormalListenerEvent { + fn mark_seen(&self, rest_client: &relay::RestClient) { + if !self.accepted { + return; + } + let rest_client = rest_client.clone(); + let event_id = self.event_id_hex.clone(); + tokio::spawn(async move { + pool::reaction_add(&rest_client, &event_id, "👀").await; + }); + } + + fn steer_or_interrupt( + self, + handling: MultipleEventHandling, + owner: Option<&str>, + pool: &mut AgentPool, + queue: &mut EventQueue, + steer_ack_tx: &mpsc::UnboundedSender, + ) { + if !self.accepted || !queue.is_channel_in_flight(self.channel_id) { + return; + } + let Some(signal) = mode_gate_signal(handling, &self.effective_author, owner) else { + return; + }; + let native_attempted = matches!(signal, ControlSignal::Steer) + && try_native_steer( + pool, + queue, + self.channel_id, + self.event_for_steer, + self.prompt_tag_for_steer, + steer_ack_tx, + ); + if !native_attempted { + signal_in_flight_task(pool, self.channel_id, signal); + } + } +} + +impl NormalListenerIngress { + fn push(self, queue: &mut EventQueue) -> QueuedNormalListenerEvent { + let Self { + buzz_event, + effective_author, + prompt_tag, + } = self; + let event_id_hex = buzz_event.event.id.to_hex(); + let event_for_steer = buzz_event.event.clone(); + let prompt_tag_for_steer = prompt_tag.clone(); + let channel_id = buzz_event.channel_id; + let accepted = queue.push(QueuedEvent { + channel_id, + event: buzz_event.event, + received_at: std::time::Instant::now(), + prompt_tag, + }); + QueuedNormalListenerEvent { + accepted, + channel_id, + effective_author, + event_id_hex, + event_for_steer, + prompt_tag_for_steer, + } } } @@ -3248,7 +3344,6 @@ async fn tokio_main() -> Result<()> { // launched by the same human). Allowlist adds the // explicit pubkey list on top, for external people; // it never revokes same-owner team bots. - // LISTENER_AUTHOR_GATE_BEGIN let Some(authorized_event) = authorize_normal_listener_event( &mut author_gate_ctx, buzz_event, @@ -3262,96 +3357,32 @@ async fn tokio_main() -> Result<()> { else { continue; }; - let (buzz_event, author_hex) = - AuthorizedNormalListenerEvent(authorized_event).into_parts(); - // LISTENER_AUTHOR_GATE_END - - let matched = filter::match_event(&buzz_event.event, buzz_event.channel_id, &rules, &pubkey_hex).await; - let prompt_tag = match matched { - Some(m) => m.prompt_tag, - None => { - tracing::debug!(channel_id = %buzz_event.channel_id, kind = buzz_event.event.kind.as_u16(), "event matched no rule — dropping"); - continue; - } + let Some(ingress) = + AuthorizedNormalListenerEvent(authorized_event) + .match_subscription(&rules, &pubkey_hex) + .await + else { + tracing::debug!("authorized event matched no rule — dropping"); + continue; }; - // The effective author was captured before queue.push() - // moved the event; the mode gate uses the same verified - // principal as the inbound author gate. - let event_id_hex = buzz_event.event.id.to_hex(); - // Clone for the non-cancelling steer fork, which - // needs the event to render the steer body. The - // clone is unconditional because we don't know - // yet whether the mode gate will demand a steer - // — checking `multiple_event_handling` here - // would couple the queueing path to the mode - // and break the existing invariant that every - // accepted event goes through `queue.push` - // first. `nostr::Event::clone` is cheap (Arc- - // backed payload) so the cost is negligible. - let event_for_steer = buzz_event.event.clone(); - let prompt_tag_for_steer = prompt_tag.clone(); - let accepted = queue.push(QueuedEvent { - channel_id: buzz_event.channel_id, - event: buzz_event.event, - received_at: std::time::Instant::now(), - prompt_tag, - }); + let queued = ingress.push(&mut queue); + // 👀 — immediate "seen" reaction, only if the event // was actually queued (not dropped by DedupMode::Drop). // Fire-and-forget: on rare fast-failure paths the // guard's cleanup may race with this add, leaving a // cosmetic stale 👀. Acceptable — see ReactionGuard docs. - if accepted { - let rc = ctx.rest_client.clone(); - let eid = event_id_hex.clone(); - tokio::spawn(async move { - pool::reaction_add(&rc, &eid, "👀").await; - }); - } - // Event is already queued. If mode requires it AND - // the channel has an in-flight task, fire cancel — - // OR take the non-cancelling (ACP steer) fork for Steer signals. - if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { - // Author eligibility (owner ∪ allowlist ∪ siblings) - // is already enforced by the inbound author gate - // above, so the mid-turn signal fires for every - // event that reaches here. - let signal = mode_gate_signal( - config.multiple_event_handling, - &author_hex, - owner_cache.get(), - ); - if let Some(signal) = signal { - // Non-cancelling fork: when the mode - // wants a Steer, attempt the - // non-cancelling path first. On accept, - // withhold the queued event and spawn an - // ack watcher; the main loop's - // `PoolEvent::SteerAck` arm decides - // success/release/fallback. On reject - // (including agents that advertise no - // steer transport at all), fall through - // to the universal cancel+merge `Steer` - // signal so the event still reaches the - // agent. - let native_attempted = matches!(signal, ControlSignal::Steer) - && try_native_steer( - &mut pool, - &mut queue, - buzz_event.channel_id, - event_for_steer, - prompt_tag_for_steer, - &steer_ack_tx, - ); - if !native_attempted { - signal_in_flight_task( - &mut pool, - buzz_event.channel_id, - signal, - ); - } - } - } + queued.mark_seen(&ctx.rest_client); + // Event is already queued. The authorized ingress + // retains its verified author and event data through + // the optional steer/interrupt decision. + queued.steer_or_interrupt( + config.multiple_event_handling, + owner_cache.get(), + &mut pool, + &mut queue, + &steer_ack_tx, + ); if pool_ready { for (channel_id, thread_tags) in dispatch_pending(&mut pool, &mut queue, &ctx, &mut last_activity) @@ -6071,7 +6102,7 @@ mod author_gate_tests { /// injecting an already-resolved relay identity. This is what makes the /// listener-to-gate wiring testable: a gate that never loads its identity /// fails these tests instead of silently degrading to the raw signer. - async fn nip11_server( + pub(super) async fn nip11_server( document: serde_json::Value, ) -> (relay::RestClient, tokio::task::JoinHandle<()>) { nip11_scripted_server(std::collections::VecDeque::from([Ok(document)])).await @@ -6152,7 +6183,7 @@ mod author_gate_tests { /// A genuine relay-signed workflow dispatch that explicitly targets `agent` /// on behalf of `owner` — the exact event shape a scheduled workflow emits. - fn relay_signed_workflow_dispatch( + pub(super) fn relay_signed_workflow_dispatch( relay_keys: &nostr::Keys, owner: &str, agent: &str, @@ -6168,18 +6199,34 @@ mod author_gate_tests { .expect("signed workflow event") } - async fn listener_boundary_scenario( + struct ListenerBoundaryScenario<'a> { listener: ListenerBoundary, - relay_keys: &nostr::Keys, - workflow_owner: &str, + relay_keys: &'a nostr::Keys, + workflow_owner: &'a str, responses: std::collections::VecDeque>, event_generation: u64, - channel_type: &str, + channel_type: &'a str, respond_to: RespondTo, allowlist: HashSet, cache_owner: bool, cache_sibling: bool, + } + + async fn listener_boundary_scenario( + scenario: ListenerBoundaryScenario<'_>, ) -> (Option, bool) { + let ListenerBoundaryScenario { + listener, + relay_keys, + workflow_owner, + responses, + event_generation, + channel_type, + respond_to, + allowlist, + cache_owner, + cache_sibling, + } = scenario; let relay_hex = relay_keys.public_key().to_hex(); let agent = nostr::Keys::generate().public_key().to_hex(); let (rest_client, server) = nip11_scripted_server(responses).await; @@ -6258,47 +6305,6 @@ mod author_gate_tests { } } - fn listener_gate_region<'a>(source: &'a str, label: &str) -> &'a str { - let begin = ["LISTENER_AUTHOR_GATE_", "BEGIN"].concat(); - let end = ["LISTENER_AUTHOR_GATE_", "END"].concat(); - let mut regions = source.split(&begin); - let _before = regions.next().expect("source has a prefix"); - let region = regions - .next() - .unwrap_or_else(|| panic!("{label} listener gate begin marker missing")); - assert!( - regions.next().is_none(), - "{label} listener gate begin marker must be unique" - ); - let mut region_parts = region.split(&end); - let body = region_parts - .next() - .expect("gate region has a body before its end marker"); - assert!( - region_parts.next().is_some(), - "{label} listener gate end marker missing" - ); - body - } - - /// The production loop segments themselves retain the authorization - /// capability edge. This complements behavioral tests at the callables: - /// replacing either loop call with a raw signer or permissive boolean - /// removes the required symbols from its marked segment and fails here. - #[test] - fn production_listener_loops_consume_authorized_event_capabilities() { - let normal = listener_gate_region(include_str!("lib.rs"), "normal"); - assert_eq!( - normal.matches("authorize_normal_listener_event(").count(), - 1 - ); - assert_eq!(normal.matches("AuthorizedNormalListenerEvent(").count(), 1); - - let setup = listener_gate_region(include_str!("setup_mode.rs"), "setup"); - assert_eq!(setup.matches("authorize_setup_listener_event(").count(), 1); - assert_eq!(setup.matches("nudge_authorized_event(").count(), 1); - } - /// Both production listener callables must attribute relay-signed workflow /// events to the workflow owner and enforce policy there. A local /// `allowed: true` replacement at either call site makes the Nobody case @@ -6309,18 +6315,20 @@ mod author_gate_tests { let relay_keys = nostr::Keys::generate(); let relay_hex = relay_keys.public_key().to_hex(); let accepted_workflow_owner = nostr::Keys::generate().public_key().to_hex(); - let accepted = listener_boundary_scenario( + let accepted = listener_boundary_scenario(ListenerBoundaryScenario { listener, - &relay_keys, - &accepted_workflow_owner, - std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), - 0, - "stream", - RespondTo::OwnerOnly, - HashSet::new(), - true, - false, - ) + relay_keys: &relay_keys, + workflow_owner: &accepted_workflow_owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::OwnerOnly, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) .await; assert!( accepted.1, @@ -6337,18 +6345,20 @@ mod author_gate_tests { let relay_keys = nostr::Keys::generate(); let relay_hex = relay_keys.public_key().to_hex(); let denied_workflow_owner = nostr::Keys::generate().public_key().to_hex(); - let denied = listener_boundary_scenario( + let denied = listener_boundary_scenario(ListenerBoundaryScenario { listener, - &relay_keys, - &denied_workflow_owner, - std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), - 0, - "stream", - RespondTo::Nobody, - HashSet::new(), - true, - false, - ) + relay_keys: &relay_keys, + workflow_owner: &denied_workflow_owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::Nobody, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) .await; assert!( !denied.1, @@ -6369,18 +6379,20 @@ mod author_gate_tests { let relay_hex = relay_keys.public_key().to_hex(); let external = nostr::Keys::generate().public_key().to_hex(); let external_allowlist = HashSet::from([external.clone()]); - let denied_external = listener_boundary_scenario( + let denied_external = listener_boundary_scenario(ListenerBoundaryScenario { listener, - &relay_keys, - &external, - std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), - 0, - "dm", - RespondTo::Allowlist, - external_allowlist, - false, - false, - ) + relay_keys: &relay_keys, + workflow_owner: &external, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Allowlist, + allowlist: external_allowlist, + cache_owner: false, + cache_sibling: false, + }) .await; assert!( !denied_external.1, @@ -6391,18 +6403,20 @@ mod author_gate_tests { let relay_keys = nostr::Keys::generate(); let relay_hex = relay_keys.public_key().to_hex(); let stranger = nostr::Keys::generate().public_key().to_hex(); - let denied_stranger = listener_boundary_scenario( + let denied_stranger = listener_boundary_scenario(ListenerBoundaryScenario { listener, - &relay_keys, - &stranger, - std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), - 0, - "dm", - RespondTo::Anyone, - HashSet::new(), - false, - false, - ) + relay_keys: &relay_keys, + workflow_owner: &stranger, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Anyone, + allowlist: HashSet::new(), + cache_owner: false, + cache_sibling: false, + }) .await; assert!( !denied_stranger.1, @@ -6426,20 +6440,20 @@ mod author_gate_tests { ] { let relay_keys = nostr::Keys::generate(); let relay_hex = relay_keys.public_key().to_hex(); - let allowed = listener_boundary_scenario( + let allowed = listener_boundary_scenario(ListenerBoundaryScenario { listener, - &relay_keys, - &principal, - std::collections::VecDeque::from([Ok( + relay_keys: &relay_keys, + workflow_owner: &principal, + responses: std::collections::VecDeque::from([Ok( serde_json::json!({ "self": relay_hex }), )]), - 0, - "dm", - RespondTo::Anyone, - HashSet::new(), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Anyone, + allowlist: HashSet::new(), cache_owner, cache_sibling, - ) + }) .await; assert!( allowed.1, @@ -6451,18 +6465,20 @@ mod author_gate_tests { let relay_keys = nostr::Keys::generate(); let relay_hex = relay_keys.public_key().to_hex(); let owner = nostr::Keys::generate().public_key().to_hex(); - let denied_nobody = listener_boundary_scenario( + let denied_nobody = listener_boundary_scenario(ListenerBoundaryScenario { listener, - &relay_keys, - &owner, - std::collections::VecDeque::from([Ok(serde_json::json!({ "self": relay_hex }))]), - 0, - "dm", - RespondTo::Nobody, - HashSet::new(), - true, - false, - ) + relay_keys: &relay_keys, + workflow_owner: &owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "dm", + respond_to: RespondTo::Nobody, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) .await; assert!( !denied_nobody.1, @@ -6481,22 +6497,22 @@ mod author_gate_tests { let relay_keys = nostr::Keys::generate(); let relay_hex = relay_keys.public_key().to_hex(); let workflow_owner = nostr::Keys::generate().public_key().to_hex(); - let result = listener_boundary_scenario( + let result = listener_boundary_scenario(ListenerBoundaryScenario { listener, - &relay_keys, - &workflow_owner, - std::collections::VecDeque::from([ + relay_keys: &relay_keys, + workflow_owner: &workflow_owner, + responses: std::collections::VecDeque::from([ Err(()), Err(()), Ok(serde_json::json!({ "self": relay_hex })), ]), - 0, - "stream", - RespondTo::OwnerOnly, - HashSet::new(), - true, - false, - ) + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::OwnerOnly, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) .await; assert!( result.1, diff --git a/crates/buzz-acp/src/setup_mode.rs b/crates/buzz-acp/src/setup_mode.rs index 6b3f08486d4..88225469aa2 100644 --- a/crates/buzz-acp/src/setup_mode.rs +++ b/crates/buzz-acp/src/setup_mode.rs @@ -432,7 +432,6 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> // Apply the same author gate as normal mode so the nudge only goes // to authors the real agent would have answered. Same DM hardening: // in DMs only owner/siblings get a nudge (fail-closed on unknown type). - // LISTENER_AUTHOR_GATE_BEGIN let Some(authorized_event) = authorize_setup_listener_event( &mut author_gate_ctx, buzz_event, @@ -460,7 +459,6 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) -> { continue; } - // LISTENER_AUTHOR_GATE_END } Ok(()) @@ -475,7 +473,7 @@ async fn nudge_authorized_event( keys: &nostr::Keys, payload: &SetupPayload, ) -> bool { - let buzz_event = authorized_event.into_event(); + let (buzz_event, effective_author) = authorized_event.into_parts(); // Apply channel/kind filter rules. let filter_matched = @@ -493,6 +491,7 @@ async fn nudge_authorized_event( keys, buzz_event.channel_id, &buzz_event.event, + &effective_author, payload, ) .await @@ -631,12 +630,13 @@ async fn handle_setup_membership( /// Build and publish a setup nudge reply to the triggering event. /// /// Threading: flat reply to the thread root if one exists; otherwise reply -/// to the triggering event itself. P-tags the asker. +/// to the triggering event itself. P-tags the verified effective asker. async fn publish_setup_nudge( publisher: &RelayEventPublisher, keys: &nostr::Keys, channel_id: Uuid, triggering_event: &nostr::Event, + recipient_hex: &str, payload: &SetupPayload, ) -> Result<()> { use buzz_sdk::ThreadRef; @@ -661,13 +661,12 @@ async fn publish_setup_nudge( }; let body = payload.nudge_body(); - let author_hex = triggering_event.pubkey.to_hex(); let event_builder = buzz_sdk::build_message( channel_id, &body, thread_ref.as_ref(), - &[&author_hex], // p-tag the asker + &[recipient_hex], // p-tag the verified effective asker false, &[], ) @@ -739,6 +738,89 @@ mod tests { )); } + #[tokio::test] + async fn authorized_workflow_nudge_mentions_effective_owner_not_relay_signer() { + let agent_keys = nostr::Keys::generate(); + let relay_keys = nostr::Keys::generate(); + let workflow_owner = nostr::Keys::generate().public_key().to_hex(); + let agent = nostr::Keys::generate().public_key().to_hex(); + let channel_id = Uuid::new_v4(); + let event = relay::BuzzEvent { + connection_generation: 0, + channel_id, + event: crate::author_gate_tests::relay_signed_workflow_dispatch( + &relay_keys, + &workflow_owner, + &agent, + ), + }; + let relay_hex = relay_keys.public_key().to_hex(); + let (rest_client, server) = + crate::author_gate_tests::nip11_server(serde_json::json!({ "self": relay_hex })).await; + let mut gate = InboundAuthorGate::connect(&rest_client, &agent, "setup nudge test").await; + let owner_cache = OwnerCache::new(Some(workflow_owner.clone())); + let channel_info = crate::pool::ChannelInfoResolver::new( + std::collections::HashMap::from([( + channel_id, + relay::ChannelInfo { + name: "workflow".into(), + channel_type: "stream".into(), + description: None, + }, + )]), + rest_client.clone(), + ); + let authorized = authorize_setup_listener_event( + &mut gate, + event, + &crate::config::RespondTo::OwnerOnly, + &HashSet::new(), + &owner_cache, + &channel_info, + &rest_client, + ) + .await + .expect("workflow owner should pass the setup author gate"); + let rules = vec![filter::SubscriptionRule { + name: "workflow".into(), + channels: filter::ChannelScope::All("all".into()), + ..Default::default() + }]; + let (publisher, mut published) = RelayEventPublisher::test_pair(); + let payload = SetupPayload { + agent_name: "Fizz".into(), + agent_pubkey: agent.clone(), + requirements: vec![], + }; + + assert!( + nudge_authorized_event( + authorized, + &rules, + &agent, + &mut HashSet::new(), + &publisher, + &agent_keys, + &payload, + ) + .await + ); + let nudge = published.recv().await.expect("setup nudge published"); + let recipients: Vec<&str> = nudge + .tags + .iter() + .filter_map(|tag| { + let values = tag.as_slice(); + (values.first().map(String::as_str) == Some("p")) + .then(|| values.get(1).map(String::as_str)) + .flatten() + }) + .collect(); + assert!(recipients.contains(&workflow_owner.as_str())); + assert!(!recipients.contains(&relay_hex.as_str())); + server.abort(); + } + #[test] fn nudge_body_names_all_requirements() { let payload = SetupPayload {