Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions crates/openab-core/src/discord.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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);
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<String>::new(),
Some(true),
Some(config::resolve_allow_all(d.allow_all_users, &d.allowed_users)),
d.allowed_users.clone(),
),
);
}
reg
};

Expand Down
Loading