diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 5a21a71bf..38f4ccef4 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -1018,6 +1018,7 @@ impl EventHandler for Handler { let dispatcher = self.dispatcher.clone(); let stt_cfg = self.stt_config.clone(); + let gate_router = self.router.clone(); tokio::spawn(async move { // Best-effort echo before the agent reply so the user can verify STT. @@ -1032,6 +1033,35 @@ impl EventHandler for Handler { let sender_id = sender.sender_id.clone(); let sender_name = sender.sender_name.clone(); + + // Shared ingress trust gate (L3 identity). Redundant-but-matching with + // Discord's own user check that already ran pre-dispatch, so it cannot + // deny anything already admitted (non-regressive). L2 (channel/thread/DM) + // stays in the adapter for Discord — its registry entry is L2-open. + // + // Bots are skipped here: Discord's `is_denied_user` has a `!is_bot` + // bypass (bot admission is handled separately by allow_bot_messages + + // trusted_bot_ids), and the shared L3 gate is human-identity only. + // Running it on bots would wrongly drop trusted bot-to-bot messages + // when allow_all_users=false (multi-agent). See PR #1270 review F1. + // Phase 1c makes this authoritative and removes the scattered check. + if l3_gate_applies(sender.is_bot) { + let decision = gate_router.gate_incoming( + "discord", + &thread_channel.channel_id, + is_dm, + &sender_id, + ); + if !decision.is_allowed() { + tracing::info!( + sender = %sender_id, + channel = %thread_channel.channel_id, + ?decision, + "discord message denied by trust gate" + ); + return; + } + } let sender_json = serde_json::to_string(&sender).unwrap(); let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id); let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &extra_blocks); @@ -2898,6 +2928,16 @@ fn is_denied_user( !is_bot && !allow_all_users && !allowed_users.contains(&user_id) } +/// Whether the shared L3 identity gate (`AdapterRouter::gate_incoming`) should run +/// for this sender. Bots bypass L3 — mirroring [`is_denied_user`]'s `!is_bot` +/// bypass — because bot admission is a separate concern (`allow_bot_messages` + +/// `trusted_bot_ids`), and L3 (`allowed_users`) is a human-identity allowlist. +/// Running L3 on bots would wrongly deny mode-admitted/trusted bots when +/// `allow_all_users=false` (multi-agent). See PR #1270 review. +fn l3_gate_applies(is_bot: bool) -> bool { + !is_bot +} + /// Returns `true` if a bot message should bypass the `allow_bot_messages` mode check. /// A trusted bot that @mentions this bot is treated the same as a human @mention — /// it can pull the bot into a thread regardless of the `allow_bot_messages` setting. @@ -3904,6 +3944,15 @@ mod tests { assert!(!is_denied_user(true, false, &allowed, 999)); } + #[test] + fn l3_gate_skips_bots_admits_humans() { + // Regression guard (#1270 F1): the shared L3 identity gate must NOT run + // for bots — mirrors is_denied_user's !is_bot bypass. Otherwise trusted / + // mode-admitted bots would be denied when allow_all_users=false. + assert!(!l3_gate_applies(true)); // bot → gate skipped + assert!(l3_gate_applies(false)); // human → gate applies + } + // --- Trusted bot mention bypass tests --- // A trusted bot @mentioning this bot bypasses allow_bot_messages mode, // treating the mention the same as a human @mention. diff --git a/src/main.rs b/src/main.rs index 25cc3db3f..e96bfa5e6 100644 --- a/src/main.rs +++ b/src/main.rs @@ -293,6 +293,26 @@ async fn main() -> anyhow::Result<()> { ), ); } + + // Discord: gate L3 (identity) only via the shared gate. Discord's L2 is + // richer than the flat allowed_channels model (threads are admitted by + // *parent* channel, DMs by allow_dm), so we leave channel/DM enforcement + // in the adapter and set L2 open here. L3 mirrors the resolved + // [discord].allow_all_users/allowed_users, so the gate agrees with + // Discord's existing user check (behavior-preserving). L2 + dispatch-path + // privatization for Discord follow once the richer channel model lands. + if let Some(d) = &cfg.discord { + reg.insert( + "discord", + TrustConfig::new( + Some(true), // L2 open — Discord's own channel/thread/DM logic still applies + Vec::::new(), + Some(true), + Some(config::resolve_allow_all(d.allow_all_users, &d.allowed_users)), + d.allowed_users.clone(), + ), + ); + } reg };