Skip to content
Draft
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
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,12 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
| `request_approval` | Suspend execution; fields: `from`, `message`, `timeout` (default 24h) |
| `delay` | Pause execution (max 300 seconds) |

**Workflow-to-agent authority:** `send_message` output is a kind `9` event signed by the relay identity advertised as NIP-11 `self`. The event carries exactly one `["buzz:workflow", "true"]` marker and exactly one `["workflow-owner", "<64-hex-pubkey>"]` authority tag. The latter names the workflow owner whose authorization is being exercised; `p` tags remain attribution and mention/wake routing only and never grant authority.

An ACP harness may evaluate a workflow message under `workflow-owner` instead of the event signer only when all of the following verify: kind `9`; signature/author equals the configured endpoint's valid NIP-11 `self`; one exact workflow marker; and one syntactically valid, unambiguous `workflow-owner`. Missing NIP-11 identity, malformed/duplicate provenance, a non-relay signer, or a bare owner `p` tag fails closed to ordinary author authorization. The derived workflow owner is then subject to the same `respond_to` policy and DM hardening as a directly authored message. This delegates no broader authority to relay-authored events.

**Discovery query correctness:** multi-value `#h` workflow filters are intersected with the authenticated reader's accessible channels and pushed into SQL before the historical `LIMIT`. Unlike the broader access-scope predicate, an explicit `#h` filter excludes channel-less global events.

**Template variables:** `{{trigger.text}}`, `{{trigger.author}}`, `{{steps.ID.output.FIELD}}`. Single-pass resolution (not recursive). Unknown variables left as literal text.

**Condition evaluation:** `evalexpr` with `HashMapContext`. Dot notation converted to underscores (`trigger.text` → `trigger_text`). Custom functions registered: `str_contains`, `str_starts_with`, `str_ends_with`, `str_len`. 100ms timeout prevents adversarial expressions from blocking.
Expand Down
151 changes: 149 additions & 2 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,48 @@ async fn is_owner_or_sibling(
/// 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.
/// 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 != buzz_core::kind::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();
if owner.len() != 64
|| !owner.chars().all(|c| c.is_ascii_hexdigit())
|| nostr::PublicKey::from_hex(&owner).is_err()
{
return None;
}
Some(owner)
}

async fn author_allowed(
respond_to: &RespondTo,
allowlist: &HashSet<String>,
Expand Down Expand Up @@ -1345,6 +1387,8 @@ async fn tokio_main() -> Result<()> {
.await
.map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?;

let relay_self = relay.relay_self().map(str::to_owned);

// Tell the relay background task the watermark so it can use
// `since = watermark - 5s` on the first REQ instead of `since=now`.
// Best-effort: a failure here is non-fatal (we just lose the startup window
Expand Down Expand Up @@ -2145,6 +2189,9 @@ async fn tokio_main() -> Result<()> {
// it never revokes same-owner team bots.
{
let author = buzz_event.event.pubkey.to_hex();
let workflow_owner =
trusted_workflow_owner(&buzz_event.event, relay_self.as_deref());
let principal = workflow_owner.as_deref().unwrap_or(&author);
// DM hardening: resolve channel type (fail-closed
// to DM) so allowlist/anyone modes cannot be
// exercised by non-owner authors inside DMs.
Expand All @@ -2153,7 +2200,7 @@ async fn tokio_main() -> Result<()> {
let allowed = author_allowed(
&config.respond_to,
&config.respond_to_allowlist,
&author,
principal,
is_dm,
&owner_cache,
&ctx.rest_client,
Expand All @@ -2162,7 +2209,9 @@ async fn tokio_main() -> Result<()> {
if !allowed {
tracing::debug!(
channel_id = %buzz_event.channel_id,
author = %buzz_event.event.pubkey.to_hex(),
author = %author,
principal = %principal,
workflow_delegated = workflow_owner.is_some(),
mode = %config.respond_to,
is_dm,
"inbound author gate — dropping event"
Expand Down Expand Up @@ -4139,6 +4188,104 @@ fn build_mcp_servers(config: &Config) -> Vec<McpServer> {
}]
}

#[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"]).unwrap());
}
if let Some(owner) = owner {
tags.push(Tag::parse(["workflow-owner", owner]).unwrap());
}
EventBuilder::new(Kind::Custom(9), "run")
.tags(tags)
.sign_with_keys(signer)
.unwrap()
}

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

#[test]
fn rejects_forged_missing_and_ambiguous_provenance() {
let relay = Keys::generate();
let attacker = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let relay_hex = relay.public_key().to_hex();
assert!(trusted_workflow_owner(
&workflow_event(&attacker, Some(&owner), true),
Some(&relay_hex)
)
.is_none());
assert!(trusted_workflow_owner(
&workflow_event(&relay, Some(&owner), false),
Some(&relay_hex)
)
.is_none());
assert!(
trusted_workflow_owner(&workflow_event(&relay, None, true), Some(&relay_hex)).is_none()
);

let duplicate = EventBuilder::new(Kind::Custom(9), "run")
.tags([
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["workflow-owner", &owner]).unwrap(),
Tag::parse(["workflow-owner", &owner]).unwrap(),
])
.sign_with_keys(&relay)
.unwrap();
assert!(trusted_workflow_owner(&duplicate, Some(&relay_hex)).is_none());

let malformed_marker = EventBuilder::new(Kind::Custom(9), "run")
.tags([
Tag::parse(["buzz:workflow", "false"]).unwrap(),
Tag::parse(["workflow-owner", &owner]).unwrap(),
])
.sign_with_keys(&relay)
.unwrap();
assert!(trusted_workflow_owner(&malformed_marker, Some(&relay_hex)).is_none());

let malformed_owner = EventBuilder::new(Kind::Custom(9), "run")
.tags([
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["workflow-owner", &owner, "extra"]).unwrap(),
])
.sign_with_keys(&relay)
.unwrap();
assert!(trusted_workflow_owner(&malformed_owner, Some(&relay_hex)).is_none());

let mut tampered = workflow_event(&relay, Some(&owner), true);
tampered.content = "tampered after signing".to_string();
assert!(trusted_workflow_owner(&tampered, Some(&relay_hex)).is_none());
}

#[test]
fn p_tags_never_supply_workflow_authority() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let event = EventBuilder::new(Kind::Custom(9), "run")
.tags([
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["p", &owner]).unwrap(),
])
.sign_with_keys(&relay)
.unwrap();
assert!(trusted_workflow_owner(&event, Some(&relay.public_key().to_hex())).is_none());
}
}

#[cfg(test)]
mod heartbeat_base_prompt_tests {
use super::*;
Expand Down
52 changes: 47 additions & 5 deletions crates/buzz-acp/src/relay.rs
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,9 @@ pub struct HarnessRelay {
keys: Keys,
/// Optional NIP-OA auth tag for relay membership delegation.
auth_tag: Option<nostr::Tag>,
/// Relay signing identity advertised by NIP-11 `self`. Relay-authored
/// workflow messages are trusted only when their signature matches this key.
relay_self: Option<String>,
/// Handle to the background task (for clean shutdown).
/// Wrapped in `Option` so `shutdown()` can take ownership without conflicting
/// with `Drop` (which only has `&mut self`).
Expand Down Expand Up @@ -599,6 +602,16 @@ impl HarnessRelay {
agent_pubkey_hex: &str,
auth_tag: Option<nostr::Tag>,
) -> Result<Self, RelayError> {
let http = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?;
// NIP-11 is the authority binding between this configured endpoint and
// its relay signing key. Missing, malformed, or unreachable info fails
// closed for workflow delegation without preventing ordinary ACP use.
let relay_self = fetch_relay_self(&http, relay_url).await;

// Perform the initial connection and auth handshake, retrying
// transient failures (dropped handshake, timeout) with bounded
// jittered backoff. A terminal error (bad URL, bad auth tag,
Expand Down Expand Up @@ -636,14 +649,11 @@ impl HarnessRelay {
event_rx,
observer_control_rx: Some(observer_control_rx),
cmd_tx,
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.connect_timeout(std::time::Duration::from_secs(5))
.build()
.map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?,
http,
relay_url: relay_url.to_string(),
keys: keys.clone(),
auth_tag,
relay_self,
bg_handle: Some(bg_handle),
})
}
Expand Down Expand Up @@ -712,6 +722,11 @@ impl HarnessRelay {
Ok(map)
}

/// Return the configured endpoint's NIP-11 relay signing identity.
pub fn relay_self(&self) -> Option<&str> {
self.relay_self.as_deref()
}

/// Build a [`RestClient`] that shares this relay's HTTP credentials.
///
/// The returned client is cheap to clone (wraps `reqwest::Client` which is
Expand Down Expand Up @@ -3467,6 +3482,33 @@ async fn send_auth_response(
/// `ws://host:port` → `http://host:port`
/// `wss://host:port` → `https://host:port`
/// Trailing slashes are stripped.
#[derive(serde::Deserialize)]
struct RelayInformationDocument {
#[serde(default, rename = "self")]
relay_self: Option<String>,
}

async fn fetch_relay_self(http: &reqwest::Client, relay_url: &str) -> Option<String> {
let response = http
.get(relay_ws_to_http(relay_url))
.header("Accept", "application/nostr+json")
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let value = response
.json::<RelayInformationDocument>()
.await
.ok()?
.relay_self?
.to_ascii_lowercase();
nostr::PublicKey::from_hex(&value)
.ok()
.map(|key| key.to_hex())
}

pub(crate) fn relay_ws_to_http(url: &str) -> String {
url.replace("wss://", "https://")
.replace("ws://", "http://")
Expand Down
Loading
Loading