From fdbb8b70097b77062cc83373c1b5fe3f1516177b Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 22:02:58 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(trust):=20Phase=201=20(discord)=20?= =?UTF-8?q?=E2=80=94=20gate=20L3=20identity=20via=20shared=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes Discord ingress through AdapterRouter::gate_incoming for the L3 (identity) layer, keyed under "discord" in the trust registry. - registry "discord" entry: L2 open + allow_dm=true (Discord's richer channel/thread/DM logic stays in the adapter — the flat allowed_channels model can't express thread-by-parent admission); L3 mirrors resolved [discord].allow_all_users/allowed_users - gate call added at the Discord dispatch spawn, redundant-but-matching with Discord's existing pre-dispatch user check → non-regressive by construction (cannot deny what already passed) Behavior-preserving. Phase 1c makes the gate authoritative and removes the scattered user check; richer Discord L2 modeling + dispatch privatization tracked in #1269. Refs #1264 #1269 --- crates/openab-core/src/discord.rs | 22 ++++++++++++++++++++++ src/main.rs | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index 5a21a71bf..cfe831270 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,27 @@ 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. + // Phase 1c makes this authoritative and removes the scattered check. + let decision = gate_router.gate_incoming( + "discord", + &thread_channel.channel_id, + false, + &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); 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 }; From 28c272a68a60aeee0da46fe14fc79a161a490f5e Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 23:14:43 -0400 Subject: [PATCH 2/3] fix(trust): Discord gate skips bots + passes real is_dm (review #1270 F1/F2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F1 (blocker): Discord's is_denied_user has a !is_bot bypass (bot admission is handled by allow_bot_messages + trusted_bot_ids). The shared L3 gate is human-identity only, so running it on bots wrongly dropped trusted bot-to-bot messages when allow_all_users=false (multi-agent). Guard the gate with !sender.is_bot. F2: pass the in-scope is_dm instead of hardcoded false (benign today with the L2-open discord entry, but avoids latent risk). Note: #1267 (gateway) is unaffected — should_skip_event's user check never had a bot bypass, so the gateway gate already matched it for bots. --- crates/openab-core/src/discord.rs | 34 +++++++++++++++++++------------ 1 file changed, 21 insertions(+), 13 deletions(-) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index cfe831270..ca7e7cf07 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -1038,21 +1038,29 @@ impl EventHandler for Handler { // 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. - let decision = gate_router.gate_incoming( - "discord", - &thread_channel.channel_id, - false, - &sender_id, - ); - if !decision.is_allowed() { - tracing::info!( - sender = %sender_id, - channel = %thread_channel.channel_id, - ?decision, - "discord message denied by trust gate" + if !sender.is_bot { + let decision = gate_router.gate_incoming( + "discord", + &thread_channel.channel_id, + is_dm, + &sender_id, ); - return; + 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); From 11c63eb2a6e1bd3d3b4fc1520b084c0d24dc4430 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 23:21:30 -0400 Subject: [PATCH 3/3] test(trust): name + test the Discord L3 bot-bypass (review #1270 F4) Extract the gate's bot-skip into l3_gate_applies(is_bot) and add a regression test (l3_gate_skips_bots_admits_humans) locking in that bots bypass the shared L3 gate (mirrors is_denied_user's !is_bot), so trusted/mode-admitted bots aren't denied when allow_all_users=false. --- crates/openab-core/src/discord.rs | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/discord.rs b/crates/openab-core/src/discord.rs index ca7e7cf07..38f4ccef4 100644 --- a/crates/openab-core/src/discord.rs +++ b/crates/openab-core/src/discord.rs @@ -1045,7 +1045,7 @@ impl EventHandler for Handler { // 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 !sender.is_bot { + if l3_gate_applies(sender.is_bot) { let decision = gate_router.gate_incoming( "discord", &thread_channel.channel_id, @@ -2928,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. @@ -3934,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.