diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 0084babfe32..cff1d18ada2 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -135,14 +135,15 @@ Controls which authors' events the harness forwards to the agent. Events from di | Flag | Env Var | Default | Description | |------|---------|---------|-------------| -| `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `allowlist`, `anyone`, `nobody`. | +| `--respond-to` | `BUZZ_ACP_RESPOND_TO` | `owner-only` | Author gate mode: `owner-only`, `owner-strict`, `allowlist`, `anyone`, `nobody`. | | `--respond-to-allowlist` | `BUZZ_ACP_RESPOND_TO_ALLOWLIST` | — | Comma-separated 64-char hex pubkeys (required when mode is `allowlist`). Owner is always implicitly included. | **Modes:** | 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. | +| `owner-only` | Forward events from the agent's registered owner and its sibling agents (any agent whose profile carries an attestation from the same owner). If no owner is set, all events are dropped until the owner is resolved. | +| `owner-strict` | Forward only events the registered owner signed, in channels and DMs. Sibling agents are dropped, so a compromised sibling cannot hand this agent work. Relay-signed workflow messages are dropped too, because a trigger can carry another author's text. If no owner is set, all events are dropped. | | `allowlist` | Forward events from the listed pubkeys plus the owner. | | `anyone` | Forward all events (no author filtering). | | `nobody` | Drop all inbound events. Agent only acts on heartbeat prompts. | diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index af117d5a599..5ac32b25ca1 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -314,7 +314,9 @@ pub enum MultipleEventHandling { /// Inbound author gate: which authors' events the harness forwards to the agent. /// -/// - `owner-only` — only the agent's registered owner (default). +/// - `owner-only` — the agent's registered owner and its sibling agents (default). +/// - `owner-strict` — the owner alone, as raw signer, in channels and DMs; never +/// siblings or owner-attributed workflow messages. /// - `allowlist` — owner + explicit pubkey list (`--respond-to-allowlist`). /// - `anyone` — all events forwarded (no author filtering). /// - `nobody` — all events dropped (proactive/heartbeat-only mode). @@ -322,6 +324,7 @@ pub enum MultipleEventHandling { pub enum RespondTo { #[default] OwnerOnly, + OwnerStrict, Allowlist, Anyone, Nobody, @@ -331,6 +334,7 @@ impl std::fmt::Display for RespondTo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::OwnerOnly => f.write_str("owner-only"), + Self::OwnerStrict => f.write_str("owner-strict"), Self::Allowlist => f.write_str("allowlist"), Self::Anyone => f.write_str("anyone"), Self::Nobody => f.write_str("nobody"), @@ -489,7 +493,7 @@ pub struct CliArgs { #[arg(long, default_value_t = false)] pub gwp_worker: bool, - /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. + /// Agent owner pubkey (64-char hex). Used by the owner-only and owner-strict gates. #[arg(long, env = "BUZZ_ACP_AGENT_OWNER")] pub agent_owner: Option, @@ -726,7 +730,7 @@ pub struct CliArgs { pub permission_mode: PermissionMode, /// Inbound author gate: which authors' events the harness forwards. - /// Modes: owner-only (default), allowlist, anyone, nobody. + /// Modes: owner-only (default), owner-strict, allowlist, anyone, nobody. #[arg( long, env = "BUZZ_ACP_RESPOND_TO", @@ -1478,7 +1482,7 @@ impl Config { RespondTo::from_str(s.trim(), true).map_err(|_| { ConfigError::ConfigFile(format!( "invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \ - (valid values: owner-only, allowlist, anyone, nobody)" + (valid values: owner-only, owner-strict, allowlist, anyone, nobody)" )) })?; } @@ -3154,6 +3158,7 @@ channels = "ALL" #[test] fn test_respond_to_display() { assert_eq!(format!("{}", RespondTo::OwnerOnly), "owner-only"); + assert_eq!(format!("{}", RespondTo::OwnerStrict), "owner-strict"); assert_eq!(format!("{}", RespondTo::Allowlist), "allowlist"); assert_eq!(format!("{}", RespondTo::Anyone), "anyone"); assert_eq!(format!("{}", RespondTo::Nobody), "nobody"); @@ -3166,6 +3171,10 @@ channels = "ALL" RespondTo::from_str("owner-only", true).unwrap(), RespondTo::OwnerOnly ); + assert_eq!( + RespondTo::from_str("owner-strict", true).unwrap(), + RespondTo::OwnerStrict + ); assert_eq!( RespondTo::from_str("allowlist", true).unwrap(), RespondTo::Allowlist @@ -3435,7 +3444,7 @@ channels = "ALL" let mode = RespondTo::from_str(s.trim(), true).map_err(|_| { ConfigError::ConfigFile(format!( "invalid value in BUZZ_ACP_ALLOWED_RESPOND_TO: '{s}' \ - (valid values: owner-only, allowlist, anyone, nobody)" + (valid values: owner-only, owner-strict, allowlist, anyone, nobody)" )) })?; set.insert(mode); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index b2e26384d6c..5d0feecd9ec 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -384,6 +384,7 @@ mod inbound_author_gate { if is_dm { return match respond_to { RespondTo::Nobody => false, + RespondTo::OwnerStrict => owner_cache.get() == Some(author), _ => is_owner_or_sibling(author, owner_cache, rest_client).await, }; } @@ -391,6 +392,11 @@ mod inbound_author_gate { RespondTo::Anyone => true, RespondTo::Nobody => false, RespondTo::OwnerOnly => is_owner_or_sibling(author, owner_cache, rest_client).await, + // Siblings are any agent carrying an attestation from the same + // owner, so one compromised sibling could hand work to another. + // Strict mode admits the owner alone; an unresolved owner admits + // nobody. + RespondTo::OwnerStrict => owner_cache.get() == Some(author), RespondTo::Allowlist => { allowlist.contains(author) || is_owner_or_sibling(author, owner_cache, rest_client).await @@ -507,8 +513,15 @@ mod inbound_author_gate { owner_cache: &OwnerCache, rest_client: &relay::RestClient, ) -> InboundAuthorGateDecision { - let effective_author = - effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex); + // Workflow attribution names the workflow's owner, but a trigger + // can carry another author's text into its message. Strict mode + // trusts only the raw signer, so an owner workflow cannot launder + // a sibling's post into an owner turn. + let effective_author = if matches!(respond_to, RespondTo::OwnerStrict) { + event.pubkey.to_hex() + } else { + effective_prompt_author(event, self.relay_self.as_deref(), &self.agent_pubkey_hex) + }; let allowed = author_allowed( respond_to, allowlist, @@ -2652,10 +2665,11 @@ async fn tokio_main() -> Result<()> { // Warn if owner-dependent mode but no owner resolved yet. if startup_owner.is_none() { match &config.respond_to { - RespondTo::OwnerOnly => { + RespondTo::OwnerOnly | RespondTo::OwnerStrict => { tracing::warn!( - "respond-to=owner-only but no owner is set — all events will be \ - dropped. Set BUZZ_AUTH_TAG or --agent-owner, or use --respond-to=anyone." + "respond-to={} but no owner is set — all events will be \ + dropped. Set BUZZ_AUTH_TAG or --agent-owner, or use --respond-to=anyone.", + config.respond_to ); } RespondTo::Allowlist => { @@ -7202,6 +7216,38 @@ mod author_gate_tests { } } + /// Strict mode must not trust workflow attribution: a message-triggered + /// owner workflow could carry a sibling's text. Both production listeners + /// must drop a genuine relay-signed dispatch that names the owner. + #[tokio::test] + async fn production_listener_boundaries_ignore_workflow_owner_under_owner_strict() { + for listener in [ListenerBoundary::Normal, ListenerBoundary::Setup] { + let relay_keys = nostr::Keys::generate(); + let relay_hex = relay_keys.public_key().to_hex(); + let owner = nostr::Keys::generate().public_key().to_hex(); + let (attributed, allowed) = listener_boundary_scenario(ListenerBoundaryScenario { + listener, + relay_keys: &relay_keys, + workflow_owner: &owner, + responses: std::collections::VecDeque::from([Ok( + serde_json::json!({ "self": relay_hex }), + )]), + event_generation: 0, + channel_type: "stream", + respond_to: RespondTo::OwnerStrict, + allowlist: HashSet::new(), + cache_owner: true, + cache_sibling: false, + }) + .await; + assert!( + !allowed && attributed.is_none(), + "{} listener must drop an owner-attributed workflow under owner-strict", + listener.name() + ); + } + } + /// Both production listener callables must attribute relay-signed workflow /// events to the workflow owner and enforce policy there. A local /// `allowed: true` replacement at either call site makes the Nobody case @@ -8071,6 +8117,62 @@ mod author_gate_tests { } } + #[tokio::test] + async fn test_owner_strict_admits_only_the_owner() { + let cache = cache_with_sibling(); + for is_dm in [false, true] { + assert!( + inbound_author_gate::test_author_allowed( + &RespondTo::OwnerStrict, + &HashSet::new(), + OWNER, + is_dm, + &cache, + &dummy_rest_client() + ) + .await, + "owner-strict must admit the owner (dm={is_dm})" + ); + for (who, label) in [ + (SIBLING, "sibling"), + (STRANGER, "stranger"), + (EXTERNAL, "external"), + ] { + assert!( + !inbound_author_gate::test_author_allowed( + &RespondTo::OwnerStrict, + &HashSet::from([EXTERNAL.to_string()]), + who, + is_dm, + &cache, + &dummy_rest_client() + ) + .await, + "owner-strict must drop the {label} (dm={is_dm})" + ); + } + } + } + + #[tokio::test] + async fn test_owner_strict_without_owner_admits_nobody() { + let cache = OwnerCache::new(None); + for is_dm in [false, true] { + assert!( + !inbound_author_gate::test_author_allowed( + &RespondTo::OwnerStrict, + &HashSet::new(), + OWNER, + is_dm, + &cache, + &dummy_rest_client() + ) + .await, + "an unresolved owner must fail closed (dm={is_dm})" + ); + } + } + // ── DM hardening ────────────────────────────────────────────────────── // // In a DM, clients auto-p-tag every participant, and an agent can be