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
4 changes: 2 additions & 2 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ Controls which authors' events the harness forwards to the agent. Events from di
| Mode | Behavior |
|------|----------|
| `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). |
| `allowlist` | Forward events from the listed pubkeys plus the owner, including DMs. |
| `anyone` | Forward all events in channels. In a DM, still only the owner (and same-owner siblings) — a stranger the agent messages cannot prompt it. |
| `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. |

The gate applies to **all** inbound events — @mentions, DMs, thread replies, and any event delivered by the relay. Owner control commands are checked **before** the gate, so the owner can still manage the harness regardless of mode:
Expand Down
70 changes: 54 additions & 16 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -226,12 +226,16 @@ async fn is_owner_or_sibling(
/// Clients auto-p-tag every DM participant, so in a DM *any* participant's
/// message looks like a mention and would fire a turn. Combined with
/// agent-initiated DMs (the agent can be asked to DM a third party), that
/// turns `anyone`/`allowlist` modes into transitive access grants: whoever
/// lands in a DM with the agent can prompt it. To close that hole, when
/// `is_dm` is true only the owner and cryptographically verified same-owner
/// 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.
/// turns `anyone` mode into a transitive access grant: whoever lands in a
/// DM with the agent can prompt it. To close that hole, `Anyone` inside a
/// DM still only admits the owner and cryptographically verified same-owner
/// siblings.
///
/// `Allowlist` ("Selected people") is an explicit grant. Those pubkeys must
/// be able to DM the agent — the settings UI does not say "channels only".
/// A stranger who is not on the list is still dropped. `Nobody` still drops
/// everything. Callers must resolve `is_dm` fail-closed: unknown channel
/// type ⇒ treat as DM.
async fn author_allowed(
respond_to: &RespondTo,
allowlist: &HashSet<String>,
Expand All @@ -243,6 +247,10 @@ async fn author_allowed(
if is_dm {
return match respond_to {
RespondTo::Nobody => false,
RespondTo::Allowlist => {
allowlist.contains(author)
|| is_owner_or_sibling(author, owner_cache, rest_client).await
}
_ => is_owner_or_sibling(author, owner_cache, rest_client).await,
};
}
Expand Down Expand Up @@ -2838,8 +2846,8 @@ async fn tokio_main() -> Result<()> {
{
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.
// to DM). `anyone` still cannot be exercised by
// non-owner authors inside DMs; allowlist can.
let is_dm =
is_dm_channel(buzz_event.channel_id, &ctx.channel_info).await;
let allowed = author_allowed(
Expand Down Expand Up @@ -5443,16 +5451,16 @@ mod author_gate_tests {
// ── DM hardening ──────────────────────────────────────────────────────
//
// In a DM, clients auto-p-tag every participant, and an agent can be
// asked to open a DM with a third party. The gate must therefore ignore
// the allowlist and `anyone` mode inside DMs: only owner + verified
// siblings fire turns.
// asked to open a DM with a third party. `anyone` must not become a
// transitive grant. `allowlist` is an explicit Selected-people grant
// and must still fire in a DM.

#[tokio::test]
async fn test_dm_rejects_allowlisted_external_pubkey() {
async fn test_dm_admits_allowlisted_external_pubkey() {
let cache = cache_with_sibling();
let allowlist = HashSet::from([EXTERNAL.to_string()]);
assert!(
!author_allowed(
author_allowed(
&RespondTo::Allowlist,
&allowlist,
EXTERNAL,
Expand All @@ -5461,7 +5469,25 @@ mod author_gate_tests {
&dummy_rest_client()
)
.await,
"an allowlisted external pubkey must NOT fire a turn inside a DM"
"an allowlisted external pubkey must fire a turn inside a DM"
);
}

#[tokio::test]
async fn test_dm_rejects_stranger_under_allowlist() {
let cache = cache_with_sibling();
let allowlist = HashSet::from([EXTERNAL.to_string()]);
assert!(
!author_allowed(
&RespondTo::Allowlist,
&allowlist,
STRANGER,
true,
&cache,
&dummy_rest_client()
)
.await,
"a stranger who is not on the allowlist must not fire a turn inside a DM"
);
}

Expand Down Expand Up @@ -5651,7 +5677,7 @@ mod author_gate_tests {
let is_dm = is_dm_channel(id, &channel_info).await;
assert!(is_dm, "unknown startup metadata must fail closed as DM");
assert!(
!author_allowed(
author_allowed(
&RespondTo::Allowlist,
&allowlist,
EXTERNAL,
Expand All @@ -5660,7 +5686,19 @@ mod author_gate_tests {
&dummy_rest_client(),
)
.await,
"an external author must not pass when startup discovery omitted metadata"
"an allowlisted external author must pass even when startup discovery omitted metadata"
);
assert!(
!author_allowed(
&RespondTo::Allowlist,
&allowlist,
STRANGER,
is_dm,
&owner_cache,
&dummy_rest_client(),
)
.await,
"a stranger must not pass when startup discovery omitted metadata"
);
}

Expand Down