fix(acp): gate relay-signed workflow messages on their attributed author (#6129)

## Problem

Scheduled workflow `send_message` actions fire and land in the channel
with correct `p` tags for the mentioned agents — but the agents never
wake. The wake-up is silently dropped.

**Root cause:** workflow messages are signed by the **relay keypair**
(`workflow_sink.rs` signs with `state.relay_keypair`), so `event.pubkey`
is the relay's pubkey, not the workflow owner. In `buzz-acp`, the
inbound author gate (`author_allowed`) runs **before** the `p`-tag
mention check. Under the default `respond_to = owner-only`, the relay
pubkey is neither the owner nor a sibling, so every workflow wake-up
dies at the gate with a debug-level `"inbound author gate — dropping
event"`.

The relay-side comment even says the mention `p` tags exist *"so
mentioned agents are woken (wake is p-tag gated)"* — but wake is also
author-gated, and that path was missed.

## Fix

Gate relay-signed workflow messages on their **attributed author** — the
pubkey that created the workflow — instead of the relay pubkey:

- **Relay:** `workflow_sink.rs` now emits an explicit
`buzz:workflow-owner` tag carrying `workflow.owner_pubkey` (the workflow
creator, which the executor already passes as `author_pubkey` and whose
channel access the relay verifies before emitting). Ownership is never
inferred from `p`-tag order; mention `p` tags play no role in
attribution.
- **Harness:** at startup, `buzz-acp` fetches the relay's NIP-11 `self`
pubkey (new `RestClient::fetch_relay_self`, public `/info` endpoint).
Best-effort: fetch failure just logs a warning and preserves pre-fix
behavior.
- **Gate:** an event that is (a) authored by the relay `self` key, (b)
tagged `buzz:workflow`, and (c) carries a well-formed
`buzz:workflow-owner` pubkey is gated on that owner, through the exact
same owner/sibling/allowlist policy as a direct author.

## Security notes (all fail closed)

- No NIP-11 `self` pubkey → no exemption.
- `buzz:workflow` / `buzz:workflow-owner` tags on a non-relay-signed
event → ignored (a member cannot forge the exemption; the relay verifies
signatures on submission and only the relay holds its key).
- Relay-signed event without the tags, or with a malformed owner value
(not 64-hex) → plain author gate.
- Who is @mentioned in the message has no bearing on whose authority is
evaluated.
- A workflow owned by a random channel member still cannot wake an
owner-only agent — the owner's pubkey must pass the same policy.

## Testing

- 7 unit tests (`workflow_attributed_author_tests`) covering
attribution, fail-closed paths, p-tag independence, malformed owner
values, and the forgery case.
- Extended the PG-gated `workflow_send_message_p_tags_mentioned_member`
integration test to assert the `buzz:workflow-owner` tag.
- `cargo test -p buzz-acp`: 785 passed, 0 failed. `cargo test -p
buzz-relay --lib workflow_sink`: 17 passed. Clippy + fmt clean. (9
pre-existing `buzz-relay` failures in unrelated
`api::media`/`api::admin` tests fail identically on the base commit
without this change.)

Found while debugging scheduled automations in a Buzz review-pipeline
channel: two cron workflows fired daily @mentions at agents that never
responded, while direct human @mentions woke them instantly.

---------

Signed-off-by: Luke Tornquist <tornquist@squareup.com>
Co-authored-by: Fizz <3a9f8a30fbb462abec1e2977b2280a7ae50c7ff794433790be15bd48bfd52d0b@buzz.block.builderlab.xyz>
This commit is contained in:
Luke Tornquist
2026-08-17 13:29:20 -04:00
committed by GitHub
co-authored by Fizz
parent 5b3f0375a2
commit 54f11219ef
3 changed files with 438 additions and 1 deletions
+389 -1
View File
@@ -2025,6 +2025,19 @@ async fn tokio_main() -> Result<()> {
}
let owner_cache = OwnerCache::new(startup_owner.clone());
// Relay `self` pubkey (NIP-11), used to recognize relay-signed workflow
// messages in the inbound author gate. Best-effort: `None` simply means
// workflow messages get no attributed-author exemption (pre-fix behavior),
// so a fetch failure degrades gracefully instead of blocking startup.
let relay_self: Option<String> = relay.rest_client().fetch_relay_self().await;
match &relay_self {
Some(pk) => tracing::info!("relay self pubkey: {pk}"),
None => tracing::warn!(
"relay self pubkey unavailable (NIP-11 fetch failed or no stable relay key) — \
relay-signed workflow messages will be dropped by the author gate"
),
}
let mut relay_observer_control_rx = None;
let mut relay_observer_publisher_task = None;
let mut relay_observer_publisher = None;
@@ -2836,7 +2849,31 @@ 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();
// Relay-signed workflow messages (workflow
// `send_message` actions) are authored by the
// relay keypair, not the workflow owner — the
// plain author gate would drop them and the
// scheduled @mention would silently never wake
// the agent. Gate them on their *attributed*
// author (the `buzz:workflow-owner` tag — the
// pubkey that created the workflow) instead.
// See `workflow_attributed_author`
// for the recognition + trust argument.
let author = match workflow_attributed_author(
&buzz_event.event,
relay_self.as_deref(),
) {
Some(attributed) => {
tracing::debug!(
channel_id = %buzz_event.channel_id,
relay_author = %buzz_event.event.pubkey.to_hex(),
attributed_author = %attributed,
"relay-signed workflow message — gating on attributed author"
);
attributed
}
None => 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.
@@ -3517,6 +3554,90 @@ fn event_mentions_agent(event: &nostr::Event, agent_pubkey_hex: &str) -> bool {
})
}
/// If `event` is a relay-signed workflow message, return its *attributed*
/// author for inbound author gating; otherwise `None`.
///
/// Workflow `send_message` actions are signed by the **relay keypair**
/// (`event.pubkey` = the relay's NIP-11 `self` key), not by the human who owns
/// the workflow — so the plain author gate would drop them even though they
/// carry `p` tags meant to wake mentioned agents. The relay attributes the
/// message to the **workflow owner** (the pubkey that created the workflow,
/// `workflow.owner_pubkey` relay-side) via the explicit `buzz:workflow-owner`
/// tag emitted by `workflow_sink.rs`, and it has already verified that owner's
/// access to the destination channel before emitting the event.
///
/// Recognition requires ALL of the following, failing closed otherwise:
/// 1. kind `9` (stream message) — the only kind the workflow sink emits;
/// 2. a known, syntactically valid relay `self` pubkey (fetched from NIP-11
/// at startup) — no `relay_self`, no exemption;
/// 3. `event.pubkey` == relay `self`, with a **valid event signature**
/// verified here. The relay verifies signatures on submission, but this
/// gate re-checks locally so the exemption never rests on an upstream
/// guarantee it can't see;
/// 4. **exactly one** tag exactly equal to `["buzz:workflow", "true"]` — no
/// duplicates, no extra fields, no other value;
/// 5. **exactly one** tag exactly equal to `["buzz:workflow-owner", <pubkey>]`
/// where the owner parses as a full pubkey — no duplicates, no extra
/// fields. Mention `p` tags are never used for attribution, so who is
/// @mentioned in the message text has no bearing on whose authority the
/// gate evaluates.
///
/// The returned pubkey is gated exactly like a direct author: owner/sibling
/// under `owner-only`, plus the explicit list under `allowlist`. A workflow
/// owned by a random channel member therefore still cannot wake an
/// owner-only agent.
fn workflow_attributed_author(event: &nostr::Event, relay_self: Option<&str>) -> Option<String> {
// 1. Kind gate first — cheapest check, and everything below only makes
// sense for the kind:9 messages the workflow sink emits.
if event.kind.as_u16() as u32 != KIND_STREAM_MESSAGE {
return None;
}
// 2. Relay identity must be known AND syntactically valid.
let relay_self = nostr::PublicKey::from_hex(relay_self?).ok()?;
if event.pubkey != relay_self {
return None;
}
// 4. Exactly one marker tag, exactly ["buzz:workflow", "true"]. Collect
// every tag with the marker key so duplicates or shape/value mismatches
// (extra fields, wrong value) disqualify instead of being skipped over.
let markers: Vec<&[String]> = event
.tags
.iter()
.map(|t| t.as_slice())
.filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow"))
.collect();
if markers.len() != 1 || markers[0] != ["buzz:workflow", "true"] {
return None;
}
// 5. Exactly one owner tag, exactly ["buzz:workflow-owner", <pubkey>].
// The owner must parse as a full pubkey — not merely look hex-ish —
// before it is fed into the owner/sibling/allowlist comparison.
let owners: Vec<&[String]> = event
.tags
.iter()
.map(|t| t.as_slice())
.filter(|s| s.first().map(|k| k.as_str()) == Some("buzz:workflow-owner"))
.collect();
let [owner_tag] = owners.as_slice() else {
return None;
};
let [_, owner_value] = owner_tag else {
return None;
};
let owner = nostr::PublicKey::from_hex(owner_value).ok()?;
// 3. Signature check last — it is the most expensive step, so only pay
// for it once every structural requirement has already passed.
if event.verify().is_err() {
return None;
}
Some(owner.to_hex())
}
fn is_owner_control_command(
event: &nostr::Event,
kind_u32: u32,
@@ -5673,6 +5794,273 @@ mod author_gate_tests {
}
}
#[cfg(test)]
mod workflow_attributed_author_tests {
use super::*;
use nostr::{EventBuilder, Keys, Kind, Tag};
/// Build a kind:9 event signed by `signer` with the given extra tags.
fn make_event(signer: &Keys, tags: Vec<Tag>) -> nostr::Event {
EventBuilder::new(Kind::from(KIND_STREAM_MESSAGE as u16), "wake up")
.tags(tags)
.sign_with_keys(signer)
.expect("sign test event")
}
fn workflow_tags(owner_hex: &str, mention_hex: &str) -> Vec<Tag> {
vec![
Tag::parse(["p", owner_hex]).unwrap(),
Tag::parse(["h", "3204e3f9-fd09-4e95-b749-76966794c287"]).unwrap(),
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["buzz:workflow-owner", owner_hex]).unwrap(),
Tag::parse(["p", mention_hex]).unwrap(),
]
}
#[test]
fn relay_signed_workflow_message_attributes_to_workflow_owner_tag() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let agent = Keys::generate().public_key().to_hex();
let event = make_event(&relay, workflow_tags(&owner, &agent));
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
Some(owner),
"a relay-signed buzz:workflow event must attribute to the \
buzz:workflow-owner tag, not any mentioned agent"
);
}
#[test]
fn attribution_ignores_p_tags_entirely() {
// Only the explicit buzz:workflow-owner tag attributes; p tags
// (owner attribution + mentions) must have no effect on the gate.
let relay = Keys::generate();
let someone = Keys::generate().public_key().to_hex();
let event = make_event(
&relay,
vec![
Tag::parse(["p", &someone]).unwrap(),
Tag::parse(["buzz:workflow", "true"]).unwrap(),
],
);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"without a buzz:workflow-owner tag there is no attributed author, \
even when p tags are present"
);
}
#[test]
fn malformed_owner_tag_value_attributes_to_no_one() {
let relay = Keys::generate();
let event = make_event(
&relay,
vec![
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["buzz:workflow-owner", "not-a-pubkey"]).unwrap(),
],
);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"a buzz:workflow-owner value that is not 64-hex must be rejected"
);
}
#[test]
fn no_relay_self_means_no_exemption() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let agent = Keys::generate().public_key().to_hex();
let event = make_event(&relay, workflow_tags(&owner, &agent));
assert_eq!(
workflow_attributed_author(&event, None),
None,
"without a known relay self pubkey the exemption must not apply (fail closed)"
);
}
#[test]
fn non_relay_author_gets_no_exemption_even_with_workflow_tag() {
// A member forging the buzz:workflow tag on their own event must not
// be able to attribute it to someone else via a p tag.
let forger = Keys::generate();
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let agent = Keys::generate().public_key().to_hex();
let event = make_event(&forger, workflow_tags(&owner, &agent));
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"a buzz:workflow tag on a non-relay-signed event must be ignored"
);
}
#[test]
fn relay_signed_message_without_workflow_tag_gets_no_exemption() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let event = make_event(&relay, vec![Tag::parse(["p", &owner]).unwrap()]);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"relay-signed events without the buzz:workflow tag keep the plain author gate"
);
}
#[test]
fn workflow_message_without_owner_tag_attributes_to_no_one() {
let relay = Keys::generate();
let event = make_event(&relay, vec![Tag::parse(["buzz:workflow", "true"]).unwrap()]);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"a workflow message with no buzz:workflow-owner tag has no attributed \
author and must fall through to the plain (relay-pubkey) author gate"
);
}
#[test]
fn duplicate_marker_tags_disqualify() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let event = make_event(
&relay,
vec![
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
],
);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"more than one buzz:workflow marker tag must fail closed"
);
}
#[test]
fn marker_value_mismatch_disqualifies() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
for bad_marker in [
Tag::parse(["buzz:workflow", "false"]).unwrap(),
Tag::parse(["buzz:workflow"]).unwrap(),
Tag::parse(["buzz:workflow", "true", "extra"]).unwrap(),
] {
let event = make_event(
&relay,
vec![
bad_marker.clone(),
Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
],
);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"marker tag {:?} is not exactly [\"buzz:workflow\", \"true\"] and must fail closed",
bad_marker.as_slice()
);
}
}
#[test]
fn duplicate_owner_tags_disqualify() {
// Two owner tags — even with identical values — are ambiguous
// provenance and must not attribute to anyone.
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let other = Keys::generate().public_key().to_hex();
for second_owner in [&owner, &other] {
let event = make_event(
&relay,
vec![
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["buzz:workflow-owner", &owner]).unwrap(),
Tag::parse(["buzz:workflow-owner", second_owner]).unwrap(),
],
);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"duplicate buzz:workflow-owner tags must fail closed"
);
}
}
#[test]
fn owner_tag_with_extra_fields_disqualifies() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let event = make_event(
&relay,
vec![
Tag::parse(["buzz:workflow", "true"]).unwrap(),
Tag::parse(["buzz:workflow-owner", &owner, "extra"]).unwrap(),
],
);
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"an owner tag with extra fields is not the exact shape the relay \
emits and must fail closed"
);
}
#[test]
fn wrong_kind_disqualifies() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let agent = Keys::generate().public_key().to_hex();
let event = EventBuilder::new(Kind::from(1u16), "wake up")
.tags(workflow_tags(&owner, &agent))
.sign_with_keys(&relay)
.expect("sign test event");
assert_eq!(
workflow_attributed_author(&event, Some(&relay.public_key().to_hex())),
None,
"only kind:9 stream messages may use the workflow exemption"
);
}
#[test]
fn tampered_event_fails_signature_check() {
// Alter the content after signing: pubkey still matches relay_self
// and the tags are pristine, but the signature no longer covers the
// event — the local verify must reject it.
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let agent = Keys::generate().public_key().to_hex();
let event = make_event(&relay, workflow_tags(&owner, &agent));
let mut json = serde_json::to_value(&event).expect("event to JSON");
json["content"] = serde_json::Value::String("tampered".into());
let tampered: nostr::Event = serde_json::from_value(json).expect("tampered event parses");
assert_eq!(
workflow_attributed_author(&tampered, Some(&relay.public_key().to_hex())),
None,
"a tampered event must fail the local signature check"
);
}
#[test]
fn syntactically_invalid_relay_self_means_no_exemption() {
let relay = Keys::generate();
let owner = Keys::generate().public_key().to_hex();
let agent = Keys::generate().public_key().to_hex();
let event = make_event(&relay, workflow_tags(&owner, &agent));
let long_not_hex = "zz".repeat(32);
for bad_self in ["", "not-hex", long_not_hex.as_str()] {
assert_eq!(
workflow_attributed_author(&event, Some(bad_self)),
None,
"an invalid NIP-11 self value {bad_self:?} must disable the exemption"
);
}
}
}
#[cfg(test)]
mod observer_snapshot_race_tests {
use super::*;
+31
View File
@@ -410,6 +410,37 @@ impl RestClient {
.await
}
/// Fetch the relay's own signing pubkey from the public NIP-11 `/info`
/// document (the `self` field, hex, normalized to lowercase).
///
/// Used by the inbound author gate to recognize relay-signed workflow
/// messages (`buzz:workflow`-tagged kind:9 events authored by the relay
/// keypair) and gate them on their *attributed* author instead.
///
/// Returns `None` when the document is unreachable, unparseable, or has
/// no valid `self` field (e.g. the relay runs with an ephemeral key).
/// Callers must treat `None` as "no relay-signed exemption" — fail closed
/// to the plain author gate, never guess a pubkey.
pub async fn fetch_relay_self(&self) -> Option<String> {
let url = format!("{}/info", self.base_url);
let resp = self
.http
.get(&url)
.header("Accept", "application/nostr+json")
.send()
.await
.ok()?;
if !resp.status().is_success() {
return None;
}
let doc: Value = resp.json().await.ok()?;
let self_hex = doc.get("self")?.as_str()?;
if self_hex.len() != 64 || !self_hex.chars().all(|c| c.is_ascii_hexdigit()) {
return None;
}
Some(self_hex.to_ascii_lowercase())
}
/// Query events via the HTTP bridge: `POST /query` with NIP-98 auth.
///
/// Accepts a slice of `nostr::Filter` (serialized as JSON array).
+18
View File
@@ -255,6 +255,9 @@ 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` tag names the workflow owner explicitly,
// so consumers (e.g. the ACP inbound author gate) can attribute
// the message without inferring ownership from `p`-tag order
// - 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![
@@ -264,6 +267,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 `@Name` mentions to channel-member pubkeys and append a
@@ -707,5 +712,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(|t| t.as_slice().first().map(|s| s.as_str()) == Some("buzz:workflow-owner"))
.and_then(|t| t.as_slice().get(1).map(|s| s.as_str()));
assert_eq!(
owner_tag,
Some(author_hex.as_str()),
"workflow owner must be named explicitly via buzz:workflow-owner \
so consumers never infer ownership from p-tag order"
);
}
}