fix(workflows): bound doorbell cause freshness

Signed-off-by: Larry <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
Larry
2026-08-14 14:11:11 -04:00
parent 3e3d80a27d
commit 7dcae6095c
2 changed files with 196 additions and 7 deletions
+1 -1
View File
@@ -545,7 +545,7 @@ Note: Both `TriggerDef` and `ActionDef` use serde internally-tagged enums. Trigg
**Workflow-to-agent authority:** A workflow `send_message` action emits a relay-signed kind `9` doorbell, not an authoritative rendered prompt. Its single `["buzz:workflow", "doorbell-v1"]` marker identifies the protocol; single `workflow-owner`, `workflow-definition` (exact kind `30620` event id plus step id), and `workflow-cause` tags bind the claimed principal, owner-signed definition, action, and typed cause (`event`, `command`, `schedule`, or `webhook`). `p` tags remain attribution and mention/wake routing only and never grant authority; additional wake targets are derived only from mentions in the owner-signed template, never from rendered cause data.
ACP grants the derived workflow owner authority only after independently verifying the relay-signed doorbell and refetching the exact, validly signed definition. It requires the definition author and sole channel tag to match the claimed owner and delivery channel, locates the referenced `send_message` step, and reconstructs its trigger context from authenticated provenance: event and command causes are exact refetched signed events (with command authorization accepting only the owner or a cryptographically resolved same-owner agent), while schedule causes must match a valid slot in the signed definition. ACP then evaluates reconstructable conditions and renders the owner-signed template locally; prompts or conditions that depend on unavailable prior-step output fail closed. Event, command, and schedule causes are semantically deduplicated across relay replays. Webhook bodies are the sole unsigned cargo: they are size-capped, visibly labeled as untrusted in the prompt, and rate-limited per definition. Manual trigger JSON deliberately does not populate webhook fields, so a human command cannot inject arbitrary trigger cargo; any future parameterized manual run must expose its values through the same explicit untrusted-cargo boundary. Missing relay identity, malformed or duplicate doorbell tags, refetch or signature failure, provenance mismatch, invalid trigger context, or unresolved rendering drops a workflow-marked relay event rather than falling back to ordinary relay-author authorization. A verified derived owner remains subject to the same `respond_to` policy and DM hardening as a directly authored message.
ACP grants the derived workflow owner authority only after independently verifying the relay-signed doorbell and refetching the exact, validly signed definition. It requires the definition author and sole channel tag to match the claimed owner and delivery channel, locates the referenced `send_message` step, and reconstructs its trigger context from authenticated provenance: event and command causes are exact refetched signed events (with command authorization accepting only the owner or a cryptographically resolved same-owner agent), while schedule causes must match a valid slot in the signed definition. The relay-signed doorbell itself must also fall within a symmetric five-minute wall-clock window at ACP admission, bounding stale delivery replay and excessive clock skew without rejecting workflows that legitimately delay or suspend after their original cause. ACP then evaluates reconstructable conditions and renders the owner-signed template locally; prompts or conditions that depend on unavailable prior-step output fail closed. Event, command, and schedule causes are semantically deduplicated across relay replays for the ACP process lifetime; restart replay of a fresh doorbell is an accepted tradeoff. Webhook bodies are the sole unsigned cargo: they are size-capped, visibly labeled as untrusted in the prompt, and rate-limited per definition. Manual trigger JSON deliberately does not populate webhook fields, so a human command cannot inject arbitrary trigger cargo; any future parameterized manual run must expose its values through the same explicit untrusted-cargo boundary. Schedule cause timestamps require the relays canonical RFC 3339 encoding so equivalent spellings cannot bypass semantic deduplication. Missing relay identity, malformed or duplicate doorbell tags, refetch or signature failure, provenance mismatch, stale or future-skewed doorbells, invalid trigger context, or unresolved rendering drops a workflow-marked relay event rather than falling back to ordinary relay-author authorization. A verified derived owner remains subject to the same `respond_to` policy and DM hardening as a directly authored message.
**Partial rollout:** mixed versions fail closed in both directions. A new relay emits only `doorbell-v1`, which an old ACP does not recognize as its legacy `"true"` envelope; an old relay omits the mandatory `doorbell-v1` provenance expected by a new ACP. Delegated workflow prompts are therefore ignored until relay and ACP both support this protocol, while ordinary messages are unaffected.
+195 -6
View File
@@ -409,18 +409,44 @@ fn command_targets_workflow(command: &nostr::Event, workflow_id: Uuid) -> bool {
/// Maximum serialized webhook cargo admitted into a doorbell prompt.
const MAX_WORKFLOW_WEBHOOK_CARGO_BYTES: usize = 61_440;
/// Maximum wall-clock distance between a relay-signed workflow doorbell and
/// admission by the harness. This bounds replay age and tolerated clock skew
/// without rejecting workflows that legitimately delay or suspend after their
/// original cause.
const WORKFLOW_DOORBELL_FRESHNESS_SECS: i64 = 300;
fn workflow_doorbell_is_fresh_at(
delivery_time: chrono::DateTime<chrono::Utc>,
now: chrono::DateTime<chrono::Utc>,
) -> bool {
(delivery_time.timestamp() - now.timestamp()).unsigned_abs()
<= WORKFLOW_DOORBELL_FRESHNESS_SECS as u64
}
fn workflow_doorbell_claim_at(
event: &nostr::Event,
relay_self: Option<&str>,
now: chrono::DateTime<chrono::Utc>,
) -> Option<WorkflowDoorbellClaim> {
let delivery_time = chrono::DateTime::from_timestamp(event.created_at.as_secs() as i64, 0)?;
if !workflow_doorbell_is_fresh_at(delivery_time, now) {
return None;
}
workflow_doorbell_claim(event, relay_self)
}
/// Refetch owner authority and cause, then render the signed template locally.
async fn trusted_workflow_doorbell(
async fn trusted_workflow_doorbell_at(
event: &nostr::Event,
channel_id: Uuid,
relay_self: Option<&str>,
rest_client: &relay::RestClient,
now: chrono::DateTime<chrono::Utc>,
) -> Option<TrustedWorkflowDoorbell> {
use buzz_workflow::executor::{resolve_template, TriggerContext};
use buzz_workflow::schema::ActionDef;
let claim = workflow_doorbell_claim(event, relay_self)?;
let claim = workflow_doorbell_claim_at(event, relay_self, now)?;
let definition = query_exact_event(claim.definition_id, rest_client).await?;
if definition.kind.as_u16() as u32 != buzz_core::kind::KIND_WORKFLOW_DEF
|| !definition
@@ -497,7 +523,9 @@ async fn trusted_workflow_doorbell(
let slot_time = chrono::DateTime::parse_from_rfc3339(slot)
.ok()?
.with_timezone(&chrono::Utc);
if !buzz_workflow::schedule_cause_matches(&workflow, slot_time) {
if slot_time.to_rfc3339() != *slot
|| !buzz_workflow::schedule_cause_matches(&workflow, slot_time)
{
return None;
}
let timestamp = slot_time.timestamp().to_string();
@@ -576,6 +604,22 @@ async fn trusted_workflow_doorbell(
})
}
async fn trusted_workflow_doorbell(
event: &nostr::Event,
channel_id: Uuid,
relay_self: Option<&str>,
rest_client: &relay::RestClient,
) -> Option<TrustedWorkflowDoorbell> {
trusted_workflow_doorbell_at(
event,
channel_id,
relay_self,
rest_client,
chrono::Utc::now(),
)
.await
}
fn with_doorbell_content(event: &nostr::Event, content: String) -> Option<nostr::Event> {
let mut value = serde_json::to_value(event).ok()?;
value["content"] = serde_json::Value::String(content);
@@ -5317,13 +5361,14 @@ mod workflow_authority_tests {
.unwrap()
}
fn doorbell(
fn doorbell_at(
relay: &Keys,
owner: &Keys,
definition: &nostr::Event,
channel: Uuid,
cause: [&str; 3],
content: &str,
created_at: nostr::Timestamp,
) -> nostr::Event {
EventBuilder::new(Kind::Custom(9), content)
.tags([
@@ -5333,10 +5378,148 @@ mod workflow_authority_tests {
Tag::parse(cause).unwrap(),
Tag::parse(["h", &channel.to_string()]).unwrap(),
])
.custom_created_at(created_at)
.sign_with_keys(relay)
.unwrap()
}
fn doorbell(
relay: &Keys,
owner: &Keys,
definition: &nostr::Event,
channel: Uuid,
cause: [&str; 3],
content: &str,
) -> nostr::Event {
doorbell_at(
relay,
owner,
definition,
channel,
cause,
content,
nostr::Timestamp::now(),
)
}
#[test]
fn doorbell_freshness_includes_boundary_and_rejects_both_directions() {
let relay = Keys::generate();
let owner = Keys::generate();
let channel = Uuid::new_v4();
let def = definition(&owner, channel, "webhook", "wake");
let now = chrono::DateTime::parse_from_rfc3339("2026-08-14T18:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let boundary = chrono::Duration::seconds(WORKFLOW_DOORBELL_FRESHNESS_SECS);
let relay_pubkey = relay.public_key().to_hex();
let claim_at = |created_at: chrono::DateTime<chrono::Utc>| {
let event = doorbell_at(
&relay,
&owner,
&def,
channel,
["workflow-cause", "webhook", ""],
"{}",
nostr::Timestamp::from(created_at.timestamp() as u64),
);
workflow_doorbell_claim_at(&event, Some(&relay_pubkey), now)
};
assert!(claim_at(now - boundary).is_some());
assert!(claim_at(now + boundary).is_some());
assert!(claim_at(now - boundary - chrono::Duration::seconds(1)).is_none());
assert!(claim_at(now + boundary + chrono::Duration::seconds(1)).is_none());
}
#[tokio::test]
async fn stale_and_future_doorbells_fail_before_all_cause_verifiers() {
let relay = Keys::generate();
let owner = Keys::generate();
let channel = Uuid::new_v4();
let def = definition(&owner, channel, "webhook", "wake");
let now = chrono::DateTime::parse_from_rfc3339("2026-08-14T18:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let outside = chrono::Duration::seconds(WORKFLOW_DOORBELL_FRESHNESS_SECS + 1);
let causes = [
["workflow-cause", "event", &"0".repeat(64)],
["workflow-cause", "command", &"1".repeat(64)],
["workflow-cause", "schedule", "2026-08-14T18:00:00+00:00"],
["workflow-cause", "webhook", ""],
];
for delivery_time in [now - outside, now + outside] {
for cause in &causes {
let pointer = doorbell_at(
&relay,
&owner,
&def,
channel,
[cause[0], cause[1], cause[2]],
if cause[1] == "webhook" { "{}" } else { "" },
nostr::Timestamp::from(delivery_time.timestamp() as u64),
);
let (client, server) = rest_client_serving(vec![]).await;
assert!(
trusted_workflow_doorbell_at(
&pointer,
channel,
Some(&relay.public_key().to_hex()),
&client,
now,
)
.await
.is_none(),
"{} cause at {delivery_time} must fail freshness",
cause[1]
);
server.await.unwrap();
}
}
}
#[tokio::test]
async fn old_schedule_slot_is_accepted_only_with_canonical_encoding_on_fresh_doorbell() {
let relay = Keys::generate();
let owner = Keys::generate();
let channel = Uuid::new_v4();
let def = definition(
&owner,
channel,
"schedule\n interval: 60s",
"slot {{trigger.timestamp}}",
);
let now = chrono::DateTime::parse_from_rfc3339("2026-08-14T18:00:00Z")
.unwrap()
.with_timezone(&chrono::Utc);
let canonical_slot = (now - chrono::Duration::hours(24)).to_rfc3339();
let equivalent_slot = canonical_slot.replace("+00:00", "Z");
for (slot, accepted) in [(canonical_slot, true), (equivalent_slot, false)] {
let pointer = doorbell_at(
&relay,
&owner,
&def,
channel,
["workflow-cause", "schedule", &slot],
"",
nostr::Timestamp::from(now.timestamp() as u64),
);
let (client, server) = rest_client_serving(vec![serde_json::json!([def])]).await;
let trusted = trusted_workflow_doorbell_at(
&pointer,
channel,
Some(&relay.public_key().to_hex()),
&client,
now,
)
.await;
assert_eq!(trusted.is_some(), accepted, "schedule slot {slot}");
server.await.unwrap();
}
}
#[test]
fn distinguished_shape_accepts_exact_pointer_and_webhook_cargo() {
let relay = Keys::generate();
@@ -5482,26 +5665,32 @@ mod workflow_authority_tests {
"message_posted",
"Hello {{trigger.author}}: {{trigger.text}}",
);
let now = chrono::Utc::now();
let source = EventBuilder::new(Kind::Custom(9), "signed source")
.tags([Tag::parse(["h", &channel.to_string()]).unwrap()])
.custom_created_at(nostr::Timestamp::from(
(now - chrono::Duration::hours(24)).timestamp() as u64,
))
.sign_with_keys(&author)
.unwrap();
let pointer = doorbell(
let pointer = doorbell_at(
&relay,
&owner,
&def,
channel,
["workflow-cause", "event", &source.id.to_hex()],
"",
nostr::Timestamp::from(now.timestamp() as u64),
);
let (client, server) =
rest_client_serving(vec![serde_json::json!([def]), serde_json::json!([source])]).await;
let trusted = trusted_workflow_doorbell(
let trusted = trusted_workflow_doorbell_at(
&pointer,
channel,
Some(&relay.public_key().to_hex()),
&client,
now,
)
.await
.expect("trusted doorbell");