From 023fe971f0e027c53701663fab4c0e32e88727fa Mon Sep 17 00:00:00 2001 From: avi-zloof Date: Mon, 17 Aug 2026 18:23:55 +0300 Subject: [PATCH] fix(acp): honor Selected people allowlist in agent DMs Allowlisted authors can wake an agent in a 1:1 DM. Anyone still cannot, so a stranger the agent messages cannot take over. Signed-off-by: avi-zloof Co-authored-by: Cursor --- crates/buzz-acp/README.md | 4 +-- crates/buzz-acp/src/lib.rs | 70 +++++++++++++++++++++++++++++--------- 2 files changed, 56 insertions(+), 18 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..04782dfb452 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -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: diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7fd40b83db1..5cec7be1eff 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -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, @@ -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, }; } @@ -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( @@ -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, @@ -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" ); } @@ -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, @@ -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" ); }