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
13 changes: 11 additions & 2 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,9 +144,18 @@ Controls which authors' events the harness forwards to the agent. Events from di
|------|----------|
| `owner-only` | Forward only events from the agent's registered owner. If no owner is set, all events are dropped until the owner is resolved. |
| `allowlist` | Forward events from the listed pubkeys plus the owner. |
| `anyone` | Forward all events (no author filtering). |
| `anyone` | Forward all authors in verified non-DM channels; DMs remain owner/sibling-only. |
| `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. |

In a verified DM, `allowlist` admits explicitly listed public keys plus the owner
and verified sibling agents. `owner-only` and `anyone` admit only owner/siblings;
`nobody` denies everyone. Removing a key from the effective allowlist revokes its
admission. Missing, failed, or unrecognized channel metadata never grants an
external author access, including under `allowlist` or `anyone`; resolution is
retried for later events. This policy is shared by normal and setup listeners.
Signature checks, relay membership, workflow attribution, and session/audience
boundaries still apply.

Relay-signed workflow messages delegate to their recorded owner only when they
explicitly target this agent with authenticated workflow-mention provenance.
The owner tag means that owner scheduled the workflow; it does not claim that
Expand All @@ -173,7 +182,7 @@ buzz messages send --channel <channel-id> --reply-to <thread-root-id> \
--mention <agent-pubkey> --content '!cancel'
```

> **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. Set `--respond-to anyone` to disable the gate entirely.
> **Note:** The default mode is `owner-only`. Agents without a registered `agent_owner_pubkey` will not respond to any events until the owner is resolved. `--respond-to anyone` broadens verified non-DM channels only.

**Examples:**

Expand Down
112 changes: 112 additions & 0 deletions crates/buzz-acp/src/author_gate_tests/dm_allowlist.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//! Exercise real signed human messages through both production listener boundaries.
use super::*;

#[tokio::test]
async fn signed_dm_policy_matrix_and_revocation() {
let (metadata_rest, metadata_server) = nip11_server(serde_json::json!([])).await;
for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] {
let sender = nostr::Keys::generate();
let author = sender.public_key().to_hex();
let agent = nostr::Keys::generate().public_key().to_hex();
let relay = nostr::Keys::generate().public_key().to_hex();
let (mut gate, rest, server) = connected_gate(&relay, &agent).await;
for channel_type in ["dm", "unknown", "unexpected", "stream", "forum"] {
let channel_id = Uuid::new_v4();
let channel_info = pool::ChannelInfoResolver::new(
HashMap::from([(
channel_id,
relay::ChannelInfo {
name: "policy test".into(),
channel_type: channel_type.into(),
description: None,
},
)]),
metadata_rest.clone(),
);
for mode in [
RespondTo::Allowlist,
RespondTo::OwnerOnly,
RespondTo::Anyone,
RespondTo::Nobody,
] {
for principal in ["external", "owner", "sibling"] {
let cache = OwnerCache::new(Some(if principal == "owner" {
author.clone()
} else {
nostr::Keys::generate().public_key().to_hex()
}));
cache.cache_sibling(author.clone(), principal == "sibling");
// The same gate sees admission, removal, and re-addition.
for listed in [true, false, true] {
let allowlist = if listed {
HashSet::from([author.clone()])
} else {
HashSet::new()
};
let event = nostr::EventBuilder::new(
nostr::Kind::Custom(KIND_STREAM_MESSAGE as u16),
"Please reply in this conversation",
)
.tags([
nostr::Tag::parse(["h", &channel_id.to_string()]).unwrap(),
nostr::Tag::parse(["p", &agent]).unwrap(),
])
.sign_with_keys(&sender)
.unwrap();
event.verify().unwrap();
let event = relay::BuzzEvent {
connection_generation: 0,
channel_id,
event,
};
let authorized = match listener {
ListenerBoundary::Normal => {
authorize_normal_listener_event(
&mut gate,
event,
&mode,
&allowlist,
&cache,
&channel_info,
&rest,
)
.await
}
ListenerBoundary::Setup => {
setup_mode::authorize_setup_listener_event(
&mut gate,
event,
&mode,
&allowlist,
&cache,
&channel_info,
&rest,
)
.await
}
};
let known = matches!(channel_type, "dm" | "stream" | "forum");
let expected = match mode {
RespondTo::Nobody => false,
_ if principal != "external" => true,
RespondTo::Allowlist => known && listed,
RespondTo::Anyone => matches!(channel_type, "stream" | "forum"),
RespondTo::OwnerOnly => false,
};
assert_eq!(
authorized.is_some(),
expected,
"{} {channel_type} {mode} {principal} listed={listed}",
listener.name()
);
if let Some(authorized) = authorized {
assert_eq!(authorized.into_parts().1, author);
}
}
}
}
}
server.abort();
}
metadata_server.abort();
}
2 changes: 1 addition & 1 deletion crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ pub enum MultipleEventHandling {
///
/// - `owner-only` — only the agent's registered owner (default).
/// - `allowlist` — owner + explicit pubkey list (`--respond-to-allowlist`).
/// - `anyone` — all events forwarded (no author filtering).
/// - `anyone` — all verified non-DM channels; DMs stay owner/sibling-only.
/// - `nobody` — all events dropped (proactive/heartbeat-only mode).
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, clap::ValueEnum)]
pub enum RespondTo {
Expand Down
Loading