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
221 changes: 217 additions & 4 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,108 @@ fn emit_runtime_lifecycle(
}
}

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

fn workflow_event(signer: &Keys, owner: Option<&str>, marker: bool) -> nostr::Event {
let mut tags = Vec::new();
if marker {
tags.push(Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"));
}
if let Some(owner) = owner {
tags.push(Tag::parse(["workflow-owner", owner]).expect("workflow-owner tag"));
}
EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt")
.tags(tags)
.sign_with_keys(signer)
.expect("signed event")
}

#[test]
fn trusted_relay_workflow_uses_workflow_owner_tag() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let event = workflow_event(&relay, Some(&owner), true);

assert_eq!(
effective_prompt_author(&event, Some(&relay.public_key().to_hex())),
owner
);
}

#[test]
fn p_tag_alone_cannot_grant_workflow_authority() {
let relay = Keys::generate();
let mentioned = Keys::generate().public_key().to_hex();
let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled prompt")
.tags([
Tag::parse(["p", mentioned.as_str()]).expect("p tag"),
Tag::parse(["buzz:workflow", "true"]).expect("workflow marker"),
])
.sign_with_keys(&relay)
.expect("signed event");

assert_eq!(
effective_prompt_author(&event, Some(&relay.public_key().to_hex())),
relay.public_key().to_hex(),
"mention p tags must not become the author-gate principal"
);
}

#[test]
fn workflow_tag_from_non_relay_cannot_forge_owner() {
let relay = Keys::generate();
let attacker = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let event = workflow_event(&attacker, Some(&owner), true);

assert_eq!(
effective_prompt_author(&event, Some(&relay.public_key().to_hex())),
attacker.public_key().to_hex()
);
}

#[test]
fn relay_signed_non_workflow_keeps_raw_author() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let event = workflow_event(&relay, Some(&owner), false);

assert_eq!(
effective_prompt_author(&event, Some(&relay.public_key().to_hex())),
relay.public_key().to_hex()
);
}

#[test]
fn missing_or_ambiguous_workflow_owner_fails_closed() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let relay_hex = relay.public_key().to_hex();

let missing = workflow_event(&relay, None, true);
assert_eq!(
effective_prompt_author(&missing, Some(&relay_hex)),
relay_hex
);

let duplicate = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "scheduled")
.tags([
Tag::parse(["buzz:workflow", "true"]).expect("marker"),
Tag::parse(["workflow-owner", owner.as_str()]).expect("owner"),
Tag::parse(["workflow-owner", owner.as_str()]).expect("owner duplicate"),
])
.sign_with_keys(&relay)
.expect("signed event");
assert_eq!(
effective_prompt_author(&duplicate, Some(&relay_hex)),
relay_hex
);
}
}

/// Resolve the agent's owner pubkey at startup.
///
/// Priority:
Expand Down Expand Up @@ -257,6 +359,54 @@ async fn author_allowed(
}
}

/// Return the workflow principal asserted by a trusted relay-generated event.
///
/// Authority requires all of: kind:9, the configured endpoint's NIP-11 `self`
/// as cryptographic author, exactly one `buzz:workflow=true` marker, and exactly
/// one valid `workflow-owner` pubkey. `p` tags are deliberately ignored here:
/// they route mentions and cannot grant authority.
fn trusted_workflow_owner(event: &nostr::Event, relay_self: Option<&str>) -> Option<String> {
let relay_self = relay_self?;
if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE
|| !event.pubkey.to_hex().eq_ignore_ascii_case(relay_self)
|| event.verify().is_err()
{
return None;
}

let workflow_markers: Vec<_> = event
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("buzz:workflow"))
.collect();
if workflow_markers.len() != 1 || workflow_markers[0].as_slice() != ["buzz:workflow", "true"] {
return None;
}

let owner_tags: Vec<_> = event
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some("workflow-owner"))
.collect();
if owner_tags.len() != 1 || owner_tags[0].as_slice().len() != 2 {
return None;
}
let owner = owner_tags[0].as_slice()[1].to_ascii_lowercase();
PublicKey::from_hex(&owner)
.ok()
.filter(|_| owner.len() == 64 && owner.chars().all(|c| c.is_ascii_hexdigit()))
.map(|pubkey| pubkey.to_hex())
}

/// Resolve the author principal used by the inbound author gate.
///
/// For trusted relay-signed workflow messages this is the dedicated
/// `workflow-owner` authority tag. Everything else keeps the raw signer so a
/// forged mention `p` tag cannot bypass the gate.
pub(crate) fn effective_prompt_author(event: &nostr::Event, relay_self: Option<&str>) -> String {
trusted_workflow_owner(event, relay_self).unwrap_or_else(|| event.pubkey.to_hex())
}

/// Resolve whether `channel_id` is a DM, for the inbound author gate.
///
/// Resolution order:
Expand Down Expand Up @@ -1637,6 +1787,26 @@ async fn tokio_main() -> Result<()> {

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

let relay_self = match relay.rest_client().relay_self().await {
Ok(Some(pubkey)) => {
tracing::debug!(relay_self = %pubkey, "resolved relay NIP-11 identity");
Some(pubkey)
}
Ok(None) => {
tracing::warn!(
"relay NIP-11 document has no valid self pubkey — relay-authored workflows will remain fail-closed"
);
None
}
Err(error) => {
tracing::warn!(
%error,
"failed to resolve relay NIP-11 identity — relay-authored workflows will remain fail-closed"
);
None
}
};

relay
.subscribe_membership_notifications()
.await
Expand Down Expand Up @@ -2445,8 +2615,13 @@ 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 raw_author = buzz_event.event.pubkey.to_hex();
let workflow_owner =
trusted_workflow_owner(&buzz_event.event, relay_self.as_deref());
let author_hex = workflow_owner
.clone()
.unwrap_or_else(|| raw_author.clone());
{
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.
Expand All @@ -2455,7 +2630,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,
Expand All @@ -2464,7 +2639,9 @@ async fn tokio_main() -> Result<()> {
if !allowed {
tracing::debug!(
channel_id = %buzz_event.channel_id,
author = %buzz_event.event.pubkey.to_hex(),
raw_author = %raw_author,
effective_author = %author_hex,
workflow_delegated = workflow_owner.is_some(),
mode = %config.respond_to,
is_dm,
"inbound author gate — dropping event"
Expand All @@ -2483,7 +2660,6 @@ 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 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 Expand Up @@ -4751,6 +4927,7 @@ mod owner_cache_tests {
#[cfg(test)]
mod author_gate_tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};

/// A `RestClient` for tests. The author-gate decisions exercised here all
/// resolve from the owner pubkey or sibling cache before any HTTP call, so
Expand Down Expand Up @@ -4778,6 +4955,42 @@ mod author_gate_tests {
cache
}

#[tokio::test]
async fn owner_only_accepts_trusted_relay_workflow_from_sibling_owner() {
let relay = Keys::generate();
let human_owner = Keys::generate();
let workflow_owner = Keys::generate();
let workflow_owner_hex = workflow_owner.public_key().to_hex();
let mentioned = Keys::generate().public_key().to_hex();
let event = EventBuilder::new(Kind::Custom(KIND_STREAM_MESSAGE as u16), "dispatch")
.tags([
// Mention routing only — must not be treated as authority.
Tag::parse(["p", mentioned.as_str()]).expect("mention p tag"),
Tag::parse(["buzz:workflow", "true"]).expect("workflow tag"),
Tag::parse(["workflow-owner", workflow_owner_hex.as_str()])
.expect("workflow-owner tag"),
])
.sign_with_keys(&relay)
.expect("signed workflow event");
let effective_author = effective_prompt_author(&event, Some(&relay.public_key().to_hex()));
assert_eq!(effective_author, workflow_owner_hex);
let cache = OwnerCache::new(Some(human_owner.public_key().to_hex()));
cache.cache_sibling(workflow_owner_hex, true);

assert!(
author_allowed(
&RespondTo::OwnerOnly,
&HashSet::new(),
&effective_author,
false,
&cache,
&dummy_rest_client(),
)
.await,
"a relay-signed workflow with workflow-owner sibling must pass OwnerOnly"
);
}

#[tokio::test]
async fn test_allowlist_accepts_sibling_not_in_allowlist() {
let cache = cache_with_sibling();
Expand Down
46 changes: 46 additions & 0 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,34 @@ fn unix_now_secs() -> u64 {
}

impl RestClient {
/// Fetch the relay's stable signing pubkey from its NIP-11 information
/// document.
///
/// The value is used only to recognize relay-authored service events. A
/// missing, malformed, or unavailable document must leave callers in the
/// fail-closed path where the event's raw signer remains authoritative.
pub async fn relay_self(&self) -> Result<Option<String>, RelayError> {
let response = self
.http
.get(&self.base_url)
.header("Accept", "application/nostr+json")
.send()
.await
.map_err(|e| RelayError::Http(format!("NIP-11 request failed: {e}")))?;

if !response.status().is_success() {
return Ok(None);
}

let document: Value = response
.json()
.await
.map_err(|e| RelayError::Http(format!("invalid NIP-11 document: {e}")))?;
Ok(normalize_relay_self(
document.get("self").and_then(Value::as_str),
))
}

/// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body.
///
/// Returns the `Authorization: Nostr <base64>` header value (without the
Expand Down Expand Up @@ -439,6 +467,11 @@ impl RestClient {
}
}

fn normalize_relay_self(value: Option<&str>) -> Option<String> {
let value = value?.to_ascii_lowercase();
(value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())).then_some(value)
}

/// Events the harness cares about.
#[derive(Debug, Clone)]
pub struct BuzzEvent {
Expand Down Expand Up @@ -4011,6 +4044,19 @@ async fn wait_for_any_ok(
mod tests {
use super::*;

#[test]
fn normalize_relay_self_accepts_and_lowercases_hex_pubkey() {
let upper = "AB".repeat(32);
assert_eq!(normalize_relay_self(Some(&upper)), Some("ab".repeat(32)));
}

#[test]
fn normalize_relay_self_rejects_missing_or_malformed_values() {
assert_eq!(normalize_relay_self(None), None);
assert_eq!(normalize_relay_self(Some("ab")), None);
assert_eq!(normalize_relay_self(Some(&"zz".repeat(32))), None);
}

#[test]
fn relay_ws_to_http_plain() {
assert_eq!(
Expand Down
19 changes: 18 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,23 @@ pub(crate) async fn run_setup_listener(config: Config, payload: SetupPayload) ->

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

let relay_self = match relay.rest_client().relay_self().await {
Ok(Some(pubkey)) => Some(pubkey),
Ok(None) => {
tracing::warn!(
"setup-mode: relay NIP-11 document has no valid self pubkey — relay-authored workflows will remain fail-closed"
);
None
}
Err(error) => {
tracing::warn!(
%error,
"setup-mode: failed to resolve relay NIP-11 identity — relay-authored workflows will remain fail-closed"
);
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 +445,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,
Expand Down
Loading