diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 7fd40b83db..ef4a748ced 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -185,6 +185,11 @@ impl OwnerCache { } } +/// Check if `author` is the exact registered owner. +fn is_owner(author: &str, owner_cache: &OwnerCache) -> bool { + owner_cache.get().is_some_and(|owner| author == owner) +} + /// Check if `author` is the owner OR a sibling (same owner via NIP-OA). /// /// For unknown authors, queries their kind:0 profile to extract the NIP-OA @@ -200,7 +205,7 @@ async fn is_owner_or_sibling( }; // Direct owner check. - if author == my_owner { + if is_owner(author, owner_cache) { return true; } @@ -217,9 +222,9 @@ async fn is_owner_or_sibling( /// Inbound author gate decision: does this author's event fire a turn? /// -/// Coarse security policy applied before subscription rules. Both `OwnerOnly` -/// and `Allowlist` accept the owner and same-owner siblings; `Allowlist` -/// additionally accepts the explicit external pubkey list. +/// Coarse security policy applied before subscription rules. `OwnerOnly` +/// accepts only the exact registered owner. `Allowlist` preserves the existing +/// owner, same-owner sibling, and explicit external pubkey behavior. /// /// # DM hardening (`is_dm`) /// @@ -227,11 +232,12 @@ async fn is_owner_or_sibling( /// 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. +/// lands in a DM with the agent can prompt it. To close that hole, `OwnerOnly` +/// remains exact-owner-only in DMs. Other responding modes admit only the +/// owner and cryptographically verified same-owner siblings — 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. async fn author_allowed( respond_to: &RespondTo, allowlist: &HashSet, @@ -243,13 +249,16 @@ async fn author_allowed( if is_dm { return match respond_to { RespondTo::Nobody => false, - _ => is_owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::OwnerOnly => is_owner(author, owner_cache), + RespondTo::Allowlist | RespondTo::Anyone => { + is_owner_or_sibling(author, owner_cache, rest_client).await + } }; } match respond_to { RespondTo::Anyone => true, RespondTo::Nobody => false, - RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, + RespondTo::OwnerOnly => is_owner(author, owner_cache), RespondTo::Allowlist => { allowlist.contains(author) || is_owner_or_sibling(author, owner_cache, rest_client).await @@ -2829,12 +2838,9 @@ async fn tokio_main() -> Result<()> { // agent. Must be AFTER !shutdown (owner can always // shut down regardless of gate mode). // - // Both OwnerOnly and Allowlist accept events from - // "siblings" — pubkeys whose agent_owner_pubkey - // matches this agent's owner (e.g. other bots - // launched by the same human). Allowlist adds the - // explicit pubkey list on top, for external people; - // it never revokes same-owner team bots. + // OwnerOnly accepts only the exact registered owner. + // Allowlist preserves same-owner siblings and adds + // the explicit pubkey list for external people. { let author = buzz_event.event.pubkey.to_hex(); // DM hardening: resolve channel type (fail-closed @@ -2909,10 +2915,9 @@ async fn tokio_main() -> Result<()> { // the channel has an in-flight task, fire cancel — // OR take the non-cancelling (ACP steer) fork for Steer signals. if accepted && queue.is_channel_in_flight(buzz_event.channel_id) { - // Author eligibility (owner ∪ allowlist ∪ siblings) - // is already enforced by the inbound author gate - // above, so the mid-turn signal fires for every - // event that reaches here. + // Author eligibility is already enforced by the + // inbound author gate above, so the mid-turn + // signal fires for every event that reaches here. let signal = mode_gate_signal( config.multiple_event_handling, &author_hex, @@ -5328,6 +5333,45 @@ mod author_gate_tests { cache } + fn signed_event(keys: &nostr::Keys, kind: u32) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(kind as u16), "trigger") + .sign_with_keys(keys) + .expect("test event must sign") + } + + fn cache_for_keys( + owner: &nostr::Keys, + sibling: &nostr::Keys, + agent: &nostr::Keys, + ) -> OwnerCache { + let cache = OwnerCache::new(Some(owner.public_key().to_hex())); + cache.cache_sibling(sibling.public_key().to_hex(), true); + cache.cache_sibling(agent.public_key().to_hex(), false); + cache + } + + async fn wildcard_matches(event: &nostr::Event, agent: &nostr::Keys) -> bool { + let rule = SubscriptionRule { + name: "wildcard".into(), + ..SubscriptionRule::default() + }; + filter::match_event(event, Uuid::new_v4(), &[rule], &agent.public_key().to_hex()) + .await + .is_some() + } + + async fn owner_only_allows(event: &nostr::Event, is_dm: bool, cache: &OwnerCache) -> bool { + author_allowed( + &RespondTo::OwnerOnly, + &HashSet::new(), + &event.pubkey.to_hex(), + is_dm, + cache, + &dummy_rest_client(), + ) + .await + } + #[tokio::test] async fn test_allowlist_accepts_sibling_not_in_allowlist() { let cache = cache_with_sibling(); @@ -5422,30 +5466,84 @@ mod author_gate_tests { } #[tokio::test] - async fn test_owner_only_admits_owner_and_sibling_to_steer() { - let cache = cache_with_sibling(); - for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { + async fn strict_owner_accepts_owner_kind9_as_work() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let event = signed_event(&owner, 9); + + assert!(wildcard_matches(&event, &agent).await); + assert!(owner_only_allows(&event, false, &cache).await); + } + + #[tokio::test] + async fn strict_owner_rejects_sibling_kind9_as_work() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let event = signed_event(&sibling, 9); + + assert!(wildcard_matches(&event, &agent).await); + assert!(!owner_only_allows(&event, false, &cache).await); + } + + #[tokio::test] + async fn strict_owner_rejects_self_authored_kind9() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let event = signed_event(&agent, 9); + + assert!(!owner_only_allows(&event, false, &cache).await); + } + + #[tokio::test] + async fn strict_owner_rejects_sibling_lifecycle_kinds_even_when_filter_wildcard_matches() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + + for kind in [5, 7, 20002] { + let event = signed_event(&sibling, kind); + assert!(wildcard_matches(&event, &agent).await, "kind {kind}"); assert!( - author_allowed( - &RespondTo::OwnerOnly, - &HashSet::new(), - who, - false, - &cache, - &dummy_rest_client() - ) - .await, - "under default OwnerOnly, the {label} must be admitted so steering can fire" + !owner_only_allows(&event, false, &cache).await, + "sibling kind {kind} must not reach work" ); } } + #[tokio::test] + async fn strict_owner_owner_message_cannot_seed_sibling_reply_chain() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let mut events = vec![signed_event(&owner, 9)]; + events.extend((0..32).map(|_| signed_event(&sibling, 9))); + + let mut eligible_count = 0; + for event in &events { + if wildcard_matches(event, &agent).await + && owner_only_allows(event, false, &cache).await + { + eligible_count += 1; + } + } + + assert_eq!(eligible_count, 1); + } + // ── 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. + // the allowlist and `anyone` mode inside DMs. OwnerOnly remains exact + // owner; other responding modes admit only owner + verified siblings. #[tokio::test] async fn test_dm_rejects_allowlisted_external_pubkey() { @@ -5483,13 +5581,20 @@ mod author_gate_tests { } #[tokio::test] - async fn test_dm_admits_owner_and_sibling_in_every_responding_mode() { + async fn strict_owner_dm_accepts_owner_but_rejects_sibling() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + + assert!(owner_only_allows(&signed_event(&owner, 9), true, &cache).await); + assert!(!owner_only_allows(&signed_event(&sibling, 9), true, &cache).await); + } + + #[tokio::test] + async fn test_dm_admits_owner_and_sibling_in_non_strict_modes() { let cache = cache_with_sibling(); - for mode in [ - RespondTo::OwnerOnly, - RespondTo::Allowlist, - RespondTo::Anyone, - ] { + for mode in [RespondTo::Allowlist, RespondTo::Anyone] { for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { assert!( author_allowed( diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 2efacce2b1..aebc29cf9b 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -4756,6 +4756,43 @@ mod tests { } } + #[test] + fn strict_owner_sibling_message_remains_readable_context() { + // The agent is neither the root author nor the sibling, so the + // agent's-newest-reply retention path cannot alter the window and the + // assertion below is about sibling visibility alone. + let agent = Keys::generate(); + let root_id = "aa".repeat(32); + let context = parse_nostr_thread_response( + json!([ + { + "id": root_id, + "pubkey": "cc".repeat(32), + "content": "owner prompt", + "created_at": 1 + }, + { + "id": "dd".repeat(32), + "pubkey": "bb".repeat(32), + "content": "sibling context", + "created_at": 2 + } + ]), + &"aa".repeat(32), + 2, + &agent.public_key(), + ) + .expect("thread context must remain readable"); + + match context { + ConversationContext::Thread { messages, .. } => { + assert_eq!(messages[1].pubkey, "bb".repeat(32)); + assert_eq!(messages[1].content, "sibling context"); + } + _ => panic!("expected thread context"), + } + } + #[test] fn test_parse_thread_response_truncated() { let json = json!({ diff --git a/docs/superpowers/plans/2026-08-10-buzz-strict-owner-source-guard.md b/docs/superpowers/plans/2026-08-10-buzz-strict-owner-source-guard.md new file mode 100644 index 0000000000..75e73ef180 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-buzz-strict-owner-source-guard.md @@ -0,0 +1,368 @@ +# Buzz Strict-Owner Source Guard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make `respond-to=owner-only` admit only the exact registered owner as automatic work while retaining sibling-authored events in readable relay context. + +**Architecture:** Keep the existing event pipeline and make the smallest change at its authoritative pre-queue boundary, `author_allowed`. Tests exercise the real author gate, real subscription matcher, signed Nostr events, and real context parser so trigger authority and visibility are independently pinned. + +**Tech Stack:** Rust 2024 workspace, Tokio tests, `nostr` signed events, Cargo test/fmt/clippy. + +## Global Constraints + +- Do not send Buzz channel traffic. +- Do not deploy or restart Buzz. +- Do not mutate production configuration. +- Do not modify Gyre, prloop, agent roles, or Buzz source outside this isolated worktree. +- Preserve current `Allowlist`, `Anyone`, `Nobody`, setup-mode reuse, and history-query semantics. +- Every focused test command must report a nonzero executed-test count. +- Stop after the source fix, tests, rollback description, and unexecuted canary proposal. + +--- + +### Task 1: Pin Trigger Authority and Context Visibility in Failing Tests + +**Files:** +- Modify: `crates/buzz-acp/src/lib.rs` in `author_gate_tests` +- Modify: `crates/buzz-acp/src/pool.rs` in `pool::tests` + +**Interfaces:** +- Consumes: `author_allowed`, `filter::match_event`, `SubscriptionRule`, `OwnerCache`, and `parse_nostr_thread_response`. +- Produces: seven `strict_owner_*` regression tests covering requirements A–F without live relay access. + +- [ ] **Step 1: Add real signed-event helpers to `author_gate_tests`** + +```rust +fn signed_event(keys: &nostr::Keys, kind: u32) -> nostr::Event { + nostr::EventBuilder::new(nostr::Kind::Custom(kind as u16), "trigger") + .sign_with_keys(keys) + .expect("test event must sign") +} + +fn cache_for_keys( + owner: &nostr::Keys, + sibling: &nostr::Keys, + agent: &nostr::Keys, +) -> OwnerCache { + let cache = OwnerCache::new(Some(owner.public_key().to_hex())); + cache.cache_sibling(sibling.public_key().to_hex(), true); + cache.cache_sibling(agent.public_key().to_hex(), false); + cache +} + +async fn wildcard_matches(event: &nostr::Event, agent: &nostr::Keys) -> bool { + let rule = SubscriptionRule { + name: "wildcard".into(), + ..SubscriptionRule::default() + }; + filter::match_event( + event, + Uuid::new_v4(), + &[rule], + &agent.public_key().to_hex(), + ) + .await + .is_some() +} + +async fn owner_only_allows(event: &nostr::Event, is_dm: bool, cache: &OwnerCache) -> bool { + author_allowed( + &RespondTo::OwnerOnly, + &HashSet::new(), + &event.pubkey.to_hex(), + is_dm, + cache, + &dummy_rest_client(), + ) + .await +} +``` + +- [ ] **Step 2: Replace the legacy owner-plus-sibling expectation with strict-owner work tests** + +Replace `test_owner_only_admits_owner_and_sibling_to_steer` and the combined +DM-mode test with these exact tests: + +```rust +#[tokio::test] +async fn strict_owner_accepts_owner_kind9_as_work() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let event = signed_event(&owner, 9); + + assert!(wildcard_matches(&event, &agent).await); + assert!(owner_only_allows(&event, false, &cache).await); +} + +#[tokio::test] +async fn strict_owner_rejects_sibling_kind9_as_work() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let event = signed_event(&sibling, 9); + + assert!(wildcard_matches(&event, &agent).await); + assert!(!owner_only_allows(&event, false, &cache).await); +} + +#[tokio::test] +async fn strict_owner_rejects_self_authored_kind9() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let event = signed_event(&agent, 9); + + assert!(!owner_only_allows(&event, false, &cache).await); +} + +#[tokio::test] +async fn strict_owner_rejects_sibling_lifecycle_kinds_even_when_filter_wildcard_matches() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + + for kind in [5, 7, 20002] { + let event = signed_event(&sibling, kind); + assert!(wildcard_matches(&event, &agent).await, "kind {kind}"); + assert!( + !owner_only_allows(&event, false, &cache).await, + "sibling kind {kind} must not reach work" + ); + } +} + +#[tokio::test] +async fn strict_owner_owner_message_cannot_seed_sibling_reply_chain() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + let mut events = vec![signed_event(&owner, 9)]; + events.extend((0..32).map(|_| signed_event(&sibling, 9))); + + let mut eligible_count = 0; + for event in &events { + if wildcard_matches(event, &agent).await + && owner_only_allows(event, false, &cache).await + { + eligible_count += 1; + } + } + + assert_eq!(eligible_count, 1); +} + +#[tokio::test] +async fn strict_owner_dm_accepts_owner_but_rejects_sibling() { + let owner = nostr::Keys::generate(); + let sibling = nostr::Keys::generate(); + let agent = nostr::Keys::generate(); + let cache = cache_for_keys(&owner, &sibling, &agent); + + assert!(owner_only_allows(&signed_event(&owner, 9), true, &cache).await); + assert!(!owner_only_allows(&signed_event(&sibling, 9), true, &cache).await); +} + +#[tokio::test] +async fn test_dm_admits_owner_and_sibling_in_non_strict_modes() { + let cache = cache_with_sibling(); + for mode in [RespondTo::Allowlist, RespondTo::Anyone] { + for (who, label) in [(OWNER, "owner"), (SIBLING, "sibling")] { + assert!( + author_allowed( + &mode, + &HashSet::new(), + who, + true, + &cache, + &dummy_rest_client(), + ) + .await, + "in a DM under {mode}, the {label} must still be admitted" + ); + } + } +} +``` + +Retain the existing allowlist and non-`OwnerOnly` DM tests so unrelated response modes remain locked. + +- [ ] **Step 3: Add the independent readable-context test** + +```rust +#[test] +fn strict_owner_sibling_message_remains_readable_context() { + let root_id = "aa".repeat(32); + let sibling = "bb".repeat(32); + let context = parse_nostr_thread_response( + serde_json::json!([ + { + "id": root_id, + "pubkey": "cc".repeat(32), + "content": "owner prompt", + "created_at": 1 + }, + { + "id": "dd".repeat(32), + "pubkey": sibling, + "content": "sibling context", + "created_at": 2 + } + ]), + &"aa".repeat(32), + ) + .expect("thread context must remain readable"); + + match context { + ConversationContext::Thread { messages, .. } => { + assert_eq!(messages[1].pubkey, "bb".repeat(32)); + assert_eq!(messages[1].content, "sibling context"); + } + _ => panic!("expected thread context"), + } +} +``` + +- [ ] **Step 4: Run the focused RED test set and retain the receipt** + +Run: + +```bash +cargo test -p buzz-acp strict_owner -- --nocapture +``` + +Expected: seven tests execute; owner, self, and context cases pass; sibling kind-9, sibling lifecycle, DM sibling, and reply-chain assertions fail because current `OwnerOnly` calls `is_owner_or_sibling`. + +--- + +### Task 2: Implement the Minimal Exact-Owner Guard + +**Files:** +- Modify: `crates/buzz-acp/src/lib.rs:188-258` +- Modify: `crates/buzz-acp/src/lib.rs:2136-2145` comments + +**Interfaces:** +- Consumes: `OwnerCache::get`, existing `RespondTo` values, and existing NIP-OA sibling lookup. +- Produces: `is_owner(author, owner_cache) -> bool`; corrected `OwnerOnly` behavior in public channels and DMs. + +- [ ] **Step 1: Add the exact-owner predicate** + +```rust +fn is_owner(author: &str, owner_cache: &OwnerCache) -> bool { + owner_cache.get().is_some_and(|owner| author == owner) +} +``` + +- [ ] **Step 2: Reuse it in sibling discovery and the `OwnerOnly` branches** + +```rust +if is_owner(author, owner_cache) { + return true; +} +``` + +For DMs: + +```rust +return match respond_to { + RespondTo::Nobody => false, + RespondTo::OwnerOnly => is_owner(author, owner_cache), + RespondTo::Allowlist | RespondTo::Anyone => { + is_owner_or_sibling(author, owner_cache, rest_client).await + } +}; +``` + +For non-DMs: + +```rust +RespondTo::OwnerOnly => is_owner(author, owner_cache), +``` + +- [ ] **Step 3: Correct comments without changing other response modes** + +State that `OwnerOnly` accepts the exact owner, `Allowlist` preserves its current owner/sibling/explicit-list behavior, and the gate still runs before subscription matching and queueing. + +- [ ] **Step 4: Run the focused GREEN test set** + +Run: + +```bash +cargo test -p buzz-acp strict_owner -- --nocapture +``` + +Expected: seven executed tests, seven passed, zero failed. + +- [ ] **Step 5: Run the focused author-gate module** + +Run: + +```bash +cargo test -p buzz-acp author_gate_tests -- --nocapture +``` + +Expected: nonzero executed tests, all passed, including unchanged allowlist and DM hardening tests. + +--- + +### Task 3: Broader Verification and Local Commit + +**Files:** +- Verify: `crates/buzz-acp/src/lib.rs` +- Verify: `crates/buzz-acp/src/pool.rs` +- Verify: `docs/superpowers/specs/2026-08-10-buzz-strict-owner-source-guard-design.md` +- Verify: `docs/superpowers/plans/2026-08-10-buzz-strict-owner-source-guard.md` + +**Interfaces:** +- Consumes: the focused green source state. +- Produces: formatting, lint, full-suite, diff, rollback, and no-deploy receipts. + +- [ ] **Step 1: Format and verify formatting** + +```bash +cargo fmt --all +cargo fmt --all -- --check +``` + +- [ ] **Step 2: Run the broader relevant test suite** + +```bash +cargo test -p buzz-acp +``` + +Expected: at least the 607 baseline tests plus the net-new regression tests execute across unit and integration targets; zero failures. + +- [ ] **Step 3: Run targeted lint** + +```bash +cargo clippy -p buzz-acp --all-targets -- -D warnings +``` + +Expected: exit zero with no warnings. + +- [ ] **Step 4: Re-derive scope and rollback from Git** + +```bash +git diff --check +git status --short +git diff --stat HEAD~1 +git diff HEAD~1 -- crates/buzz-acp/src/lib.rs crates/buzz-acp/src/pool.rs +``` + +Confirm no production configuration, Buzz Desktop, Gyre, or prloop files appear. Rollback is the inverse of the exact source/test diff or removal of the local fix commit. + +- [ ] **Step 5: Commit the source fix locally** + +```bash +git add -- crates/buzz-acp/src/lib.rs crates/buzz-acp/src/pool.rs docs/superpowers/plans/2026-08-10-buzz-strict-owner-source-guard.md +git commit -m "fix(acp): separate owner triggers from sibling context" +``` + +- [ ] **Step 6: Propose, but do not execute, a bounded live canary** + +The proposal must require a build/deploy authorization, retained lifecycle kind exclusions, one intended seat first, passive observation, exactly one owner text message, no synthetic lifecycle event, bounded process/log checks, and immediate rollback on any sibling-triggered turn. diff --git a/docs/superpowers/specs/2026-08-10-buzz-strict-owner-source-guard-design.md b/docs/superpowers/specs/2026-08-10-buzz-strict-owner-source-guard-design.md new file mode 100644 index 0000000000..e53e42f8e2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-buzz-strict-owner-source-guard-design.md @@ -0,0 +1,76 @@ +# Buzz Strict-Owner Source Guard Design + +## Objective + +Separate message visibility from work-trigger authority in `buzz-acp`: + +- an event signed by the registered owner may start or steer work when it also matches the active subscription rule; +- an event signed by this agent is ignored; +- an event signed by a same-owner managed sibling does not start or steer work under `respond-to=owner-only`; +- sibling events remain stored, queryable, and renderable as conversation context. + +This change is source-and-tests only. It does not deploy a binary, restart Buzz, mutate production configuration, alter Gyre or prloop, or send Buzz traffic. + +## Verified Current Path + +The relay event loop in `crates/buzz-acp/src/lib.rs` performs these decisions in order: + +1. drop self-authored events when `ignore_self` is enabled; +2. recognize owner control commands; +3. call `author_allowed`; +4. call `filter::match_event`; +5. enqueue an accepted event with `EventQueue::push`; +6. allow the accepted event to start a turn or signal an in-flight turn. + +`author_allowed` currently implements `RespondTo::OwnerOnly` as owner **or** a same-owner sibling verified through NIP-OA. The executable regression witness `test_owner_only_admits_owner_and_sibling_to_steer` passed before this change with one genuine test executed. + +Conversation context takes a separate read path in `crates/buzz-acp/src/pool.rs`: it queries relay history and parses returned events without consulting `author_allowed`. Therefore trigger rejection does not require hiding or deleting sibling messages. + +## Identity Basis + +- **Owner:** exact event-author pubkey equality with the owner resolved from a cryptographically verified `BUZZ_AUTH_TAG`, falling back to the explicitly configured `BUZZ_ACP_AGENT_OWNER` pubkey. +- **Self:** exact event-author pubkey equality with the public key derived from this harness's signing key. +- **Sibling:** a different pubkey whose kind-0 profile contains a cryptographically verified NIP-OA attestation for the same owner. + +The strict-owner decision does not need a successful sibling lookup. It accepts only the exact owner pubkey and rejects every other pubkey fail-closed. NIP-OA sibling discovery remains available to the unchanged `allowlist` and DM-hardening behavior of other response modes. + +## Chosen Guard + +Correct the existing `RespondTo::OwnerOnly` branch to match its documented contract: exact owner only. + +- In public channels, `OwnerOnly` returns true only for the registered owner. +- In DMs, `OwnerOnly` returns true only for the registered owner. +- `Allowlist`, `Anyone`, and `Nobody` retain their current behavior. +- Setup mode inherits the corrected result because it calls the same `author_allowed` function. +- The existing self-authored early drop remains unchanged. +- Subscription matching, queueing, reactions, dispatch, steering, and history queries remain unchanged. + +No new configuration mode or environment variable is introduced. The default `respond-to=owner-only` becomes consistent with the existing CLI and README documentation. + +## Test Design + +All regression tests use real Buzz decision functions and signed Nostr events or real relay-response parsing. They do not use the live relay or synthetic live traffic. + +| Requirement | Test boundary | +|---|---| +| A. Owner kind-9 starts work | Exact owner passes `author_allowed`; the signed kind-9 event matches an all-channel kind-9 subscription rule. | +| B. Sibling kind-9 does not start work | Cached verified sibling fails `author_allowed` under `OwnerOnly`, before matching, queueing, or steering. | +| C. Sibling remains readable context | A relay query response containing the sibling pubkey and content is parsed into `ConversationContext` with both fields intact. | +| D. Self is ignored | The existing self-ignore predicate is extracted into a small pure helper used by the event loop and tested for enabled/disabled behavior; strict owner also rejects the distinct self key. | +| E. Lifecycle kinds cannot recurse | Signed sibling events of kinds 5, 7, and 20002 are rejected at the author boundary even against a wildcard subscription that would otherwise match them. | +| F. One owner event cannot produce an unbounded sibling chain | A bounded sequence containing one owner kind-9 event followed by many sibling kind-9 events yields exactly one trigger-eligible event. | + +The TDD red run must execute the new tests with a nonzero count and fail because the current `OwnerOnly` branch admits siblings. After the minimal source edit, the same focused tests must pass. The broader gate is the complete `buzz-acp` test suite with explicit test counts. + +## Risks and Compatibility + +- Deployments that relied on undocumented sibling wakeups while using `owner-only` will stop receiving those automatic triggers. They may still read sibling output from channel history. +- Explicit `allowlist` and `anyone` deployments remain behaviorally unchanged; this avoids silently narrowing unrelated users or channels. +- An absent or invalid owner identity remains fail-closed, so owner messages cannot wake the agent until identity is configured correctly. +- The source guard prevents recursive agent-authored work. Kind filtering remains an independent defense against unrelated event kinds and is not changed here. + +## Rollback and Live Boundary + +Rollback is the inverse source diff restoring `OwnerOnly` to `is_owner_or_sibling` and removing the new regression tests/helper. The work is isolated on `fix/buzz-strict-owner-source-guard`; the user's existing main-checkout modification is untouched. + +After source tests pass, the handoff may propose a bounded live canary. It must not execute that canary, deploy the binary, restart agents, mutate production configuration, or send a Buzz message without explicit authorization.