Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ mod queue;
mod relay;
mod setup_mode;
mod usage;
mod workflow_author;

pub use usage::TurnUsage;

Expand Down Expand Up @@ -1353,6 +1354,25 @@ async fn tokio_main() -> Result<()> {

tracing::info!("connected to relay at {}", config.relay_url);

let relay_trusted_pubkey = match relay.rest_client().fetch_nip11_relay_self().await {
Ok(Some(hex)) => {
tracing::info!("relay NIP-11 identity loaded for workflow author attribution");
Some(hex)
}
Ok(None) => {
tracing::warn!(
"relay NIP-11 document has no self pubkey — workflow author attribution disabled (fail closed)"
);
None
}
Err(error) => {
tracing::warn!(
"failed to fetch relay NIP-11 identity ({error}) — workflow author attribution disabled (fail closed)"
);
None
}
};

relay
.subscribe_membership_notifications()
.await
Expand Down Expand Up @@ -2143,7 +2163,10 @@ async fn tokio_main() -> Result<()> {
// explicit pubkey list on top, for external people;
// it never revokes same-owner team bots.
{
let author = buzz_event.event.pubkey.to_hex();
let author = workflow_author::inbound_author_hex(
&buzz_event.event,
relay_trusted_pubkey.as_deref(),
);
// DM hardening: resolve channel type (fail-closed
// to DM) so allowlist/anyone modes cannot be
// exercised by non-owner authors inside DMs.
Expand All @@ -2161,7 +2184,8 @@ async fn tokio_main() -> Result<()> {
if !allowed {
tracing::debug!(
channel_id = %buzz_event.channel_id,
author = %buzz_event.event.pubkey.to_hex(),
author = %author,
signer = %buzz_event.event.pubkey.to_hex(),
mode = %config.respond_to,
is_dm,
"inbound author gate — dropping event"
Expand All @@ -2180,7 +2204,10 @@ async fn tokio_main() -> Result<()> {
};
// Capture author pubkey before queue.push() moves
// buzz_event.event (needed for mode gate below).
let author_hex = buzz_event.event.pubkey.to_hex();
let author_hex = workflow_author::inbound_author_hex(
&buzz_event.event,
relay_trusted_pubkey.as_deref(),
);
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
Expand Down
30 changes: 30 additions & 0 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,36 @@ impl RestClient {
.await
}

/// Fetch the relay's NIP-11 `self` pubkey (hex), used to verify relay-signed
/// workflow output before attributing the workflow owner at the author gate.
pub async fn fetch_nip11_relay_self(&self) -> Result<Option<String>, RelayError> {
let url = format!("{}/", self.base_url.trim_end_matches('/'));
let resp = self
.http
.get(&url)
.header("Accept", "application/nostr+json")
.send()
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
if !resp.status().is_success() {
return Err(RelayError::Http(format!(
"NIP-11 fetch failed: HTTP {}",
resp.status()
)));
}
let doc: serde_json::Value = resp
.json()
.await
.map_err(|e| RelayError::Http(e.to_string()))?;
let relay_self = doc
.get("self")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string);
Ok(relay_self)
}

/// Query events via the HTTP bridge: `POST /query` with NIP-98 auth.
///
/// Accepts a slice of `nostr::Filter` (serialized as JSON array).
Expand Down
21 changes: 20 additions & 1 deletion crates/buzz-acp/src/setup_mode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -342,6 +342,22 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) ->

tracing::info!("setup-mode: connected and subscribed to membership notifications");

let relay_trusted_pubkey = match relay.rest_client().fetch_nip11_relay_self().await {
Ok(Some(hex)) => Some(hex),
Ok(None) => {
tracing::warn!(
"setup-mode: relay NIP-11 has no self pubkey — workflow author attribution disabled"
);
None
}
Err(error) => {
tracing::warn!(
"setup-mode: failed to fetch relay NIP-11 identity ({error}) — workflow author attribution disabled"
);
None
}
};

// 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);
Expand Down Expand Up @@ -428,7 +444,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 author_hex = buzz_event.event.pubkey.to_hex();
let author_hex = crate::workflow_author::inbound_author_hex(
&buzz_event.event,
relay_trusted_pubkey.as_deref(),
);
let is_dm = crate::is_dm_channel(buzz_event.channel_id, &channel_info).await;
let allowed = author_allowed(
&config.respond_to,
Expand Down
211 changes: 211 additions & 0 deletions crates/buzz-acp/src/workflow_author.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
//! Resolve the effective author of relay-signed workflow output for the inbound
//! author gate.
//!
//! Workflow `send_message` events are signed by the relay keypair, not the
//! workflow owner. Under default `RespondTo::OwnerOnly`, treating
//! `event.pubkey` as the author drops the event before mention matching runs.
//! When provenance checks pass, attribute the message to the workflow owner
//! carried in the `actor` tag (preferred) or the first `p` tag (legacy path).

use nostr::Event;

/// Returns true when `event` carries an exact `["buzz:workflow", "true"]` tag.
pub(crate) fn has_workflow_marker(event: &Event) -> bool {
event.tags.iter().any(|tag| {
tag.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow")
&& tag.as_slice().get(1).map(|s| s.as_str()) == Some("true")
})
}

/// Extract the workflow owner's hex pubkey from attribution tags.
///
/// Prefers `actor`, then the first `p` tag — matching
/// `buzz-relay::handlers::ingest::effective_message_author`.
pub(crate) fn workflow_owner_pubkey_hex(event: &Event) -> Option<String> {
if let Some(hex) = event.tags.iter().find_map(|tag| {
if tag.kind().to_string() == "actor" {
tag.content().map(str::to_string)
} else {
None
}
}) {
if nostr::PublicKey::from_hex(&hex).is_ok() {
return Some(hex);
}
}
for tag in event.tags.iter() {
if tag.kind().to_string() != "p" {
continue;
}
if let Some(hex) = tag.content() {
if nostr::PublicKey::from_hex(hex).is_ok() {
return Some(hex.to_string());
}
}
}
None
}

/// When `event` is a relay-signed workflow message, return the attributed owner
/// hex pubkey. Fail closed when the marker is present but provenance is missing
/// or ambiguous.
pub(crate) fn workflow_owner_if_trusted(
event: &Event,
relay_self_hex: Option<&str>,
) -> Option<String> {
if !has_workflow_marker(event) {
return None;
}
let relay_self = relay_self_hex?;
if event.pubkey.to_hex() != relay_self {
tracing::debug!(
event_id = %event.id.to_hex(),
signer = %event.pubkey.to_hex(),
"workflow marker present but signer is not the relay identity — fail closed"
);
return None;
}
let owner = workflow_owner_pubkey_hex(event)?;
Some(owner)
}

/// Author hex used by `author_allowed` and prompt metadata.
pub(crate) fn inbound_author_hex(event: &Event, relay_self_hex: Option<&str>) -> String {
workflow_owner_if_trusted(event, relay_self_hex).unwrap_or_else(|| event.pubkey.to_hex())
}

#[cfg(test)]
mod tests {
use super::*;
use nostr::{EventBuilder, Kind, Tag};

fn owner_hex() -> String {
nostr::Keys::generate().public_key().to_hex()
}

fn agent_hex() -> String {
nostr::Keys::generate().public_key().to_hex()
}

fn relay_keys() -> nostr::Keys {
nostr::Keys::generate()
}

fn relay_hex(keys: &nostr::Keys) -> String {
keys.public_key().to_hex()
}

fn workflow_message(relay: &nostr::Keys, tags: Vec<Tag>, content: &str) -> Event {
EventBuilder::new(Kind::Custom(9), content)
.tags(tags)
.sign_with_keys(relay)
.expect("sign workflow test event")
}

#[test]
fn has_workflow_marker_requires_true_value() {
let relay = relay_keys();
let event = workflow_message(
&relay,
vec![Tag::parse(["buzz:workflow", "true"]).expect("tag")],
"hi",
);
assert!(has_workflow_marker(&event));
let other = workflow_message(
&relay,
vec![Tag::parse(["buzz:workflow", "false"]).expect("tag")],
"hi",
);
assert!(!has_workflow_marker(&other));
}

#[test]
fn workflow_owner_prefers_actor_over_p() {
let relay = relay_keys();
let owner = owner_hex();
let agent = agent_hex();
let event = workflow_message(
&relay,
vec![
Tag::parse(["p", agent.as_str()]).expect("p"),
Tag::parse(["actor", owner.as_str()]).expect("actor"),
Tag::parse(["buzz:workflow", "true"]).expect("wf"),
],
"@agent hi",
);
assert_eq!(workflow_owner_pubkey_hex(&event).as_deref(), Some(owner.as_str()));
}

#[test]
fn workflow_owner_falls_back_to_first_p_tag() {
let relay = relay_keys();
let owner = owner_hex();
let agent = agent_hex();
let event = workflow_message(
&relay,
vec![
Tag::parse(["p", owner.as_str()]).expect("owner"),
Tag::parse(["p", agent.as_str()]).expect("mention"),
Tag::parse(["buzz:workflow", "true"]).expect("wf"),
],
"@agent hi",
);
assert_eq!(workflow_owner_pubkey_hex(&event).as_deref(), Some(owner.as_str()));
}

#[test]
fn trusted_workflow_resolves_owner_for_author_gate() {
let relay = relay_keys();
let relay_hex = relay_hex(&relay);
let owner = owner_hex();
let agent = agent_hex();
let event = workflow_message(
&relay,
vec![
Tag::parse(["p", owner.as_str()]).expect("owner"),
Tag::parse(["p", agent.as_str()]).expect("mention"),
Tag::parse(["buzz:workflow", "true"]).expect("wf"),
],
"@agent hi",
);
assert_eq!(inbound_author_hex(&event, Some(&relay_hex)), owner);
}

#[test]
fn untrusted_signer_fails_closed() {
let relay_hex = relay_hex(&relay_keys());
let owner = owner_hex();
let stranger = nostr::Keys::generate();
let event = EventBuilder::new(Kind::Custom(9), "forged")
.tags([
Tag::parse(["p", owner.as_str()]).expect("owner"),
Tag::parse(["buzz:workflow", "true"]).expect("wf"),
])
.sign_with_keys(&stranger)
.expect("sign forged event");
assert_eq!(
inbound_author_hex(&event, Some(&relay_hex)),
stranger.public_key().to_hex()
);
}

#[test]
fn missing_relay_identity_fails_closed() {
let relay = relay_keys();
let relay_hex = relay_hex(&relay);
let owner = owner_hex();
let event = workflow_message(
&relay,
vec![
Tag::parse(["p", owner.as_str()]).expect("owner"),
Tag::parse(["buzz:workflow", "true"]).expect("wf"),
],
"hi",
);
assert_eq!(
inbound_author_hex(&event, None),
relay_hex,
"without relay self, fall back to event signer"
);
}
}