Skip to content

Commit 709d03f

Browse files
authored
feat(trust): Phase 1 (discord) — L3 identity via shared gate [DRAFT: canary before merge] (#1270)
* feat(trust): Phase 1 (discord) — gate L3 identity via shared gate 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 * fix(trust): Discord gate skips bots + passes real is_dm (review #1270 F1/F2) 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. * 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. --------- Co-authored-by: chaodu-agent <chaodu-agent@users.noreply.github.com>
1 parent 22a3cb2 commit 709d03f

2 files changed

Lines changed: 69 additions & 0 deletions

File tree

crates/openab-core/src/discord.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1018,6 +1018,7 @@ impl EventHandler for Handler {
10181018

10191019
let dispatcher = self.dispatcher.clone();
10201020
let stt_cfg = self.stt_config.clone();
1021+
let gate_router = self.router.clone();
10211022

10221023
tokio::spawn(async move {
10231024
// Best-effort echo before the agent reply so the user can verify STT.
@@ -1032,6 +1033,35 @@ impl EventHandler for Handler {
10321033

10331034
let sender_id = sender.sender_id.clone();
10341035
let sender_name = sender.sender_name.clone();
1036+
1037+
// Shared ingress trust gate (L3 identity). Redundant-but-matching with
1038+
// Discord's own user check that already ran pre-dispatch, so it cannot
1039+
// deny anything already admitted (non-regressive). L2 (channel/thread/DM)
1040+
// stays in the adapter for Discord — its registry entry is L2-open.
1041+
//
1042+
// Bots are skipped here: Discord's `is_denied_user` has a `!is_bot`
1043+
// bypass (bot admission is handled separately by allow_bot_messages +
1044+
// trusted_bot_ids), and the shared L3 gate is human-identity only.
1045+
// Running it on bots would wrongly drop trusted bot-to-bot messages
1046+
// when allow_all_users=false (multi-agent). See PR #1270 review F1.
1047+
// Phase 1c makes this authoritative and removes the scattered check.
1048+
if l3_gate_applies(sender.is_bot) {
1049+
let decision = gate_router.gate_incoming(
1050+
"discord",
1051+
&thread_channel.channel_id,
1052+
is_dm,
1053+
&sender_id,
1054+
);
1055+
if !decision.is_allowed() {
1056+
tracing::info!(
1057+
sender = %sender_id,
1058+
channel = %thread_channel.channel_id,
1059+
?decision,
1060+
"discord message denied by trust gate"
1061+
);
1062+
return;
1063+
}
1064+
}
10351065
let sender_json = serde_json::to_string(&sender).unwrap();
10361066
let thread_key = dispatcher.key("discord", &thread_channel.channel_id, &sender_id);
10371067
let estimated_tokens = crate::dispatch::estimate_tokens(&prompt, &extra_blocks);
@@ -2898,6 +2928,16 @@ fn is_denied_user(
28982928
!is_bot && !allow_all_users && !allowed_users.contains(&user_id)
28992929
}
29002930

2931+
/// Whether the shared L3 identity gate (`AdapterRouter::gate_incoming`) should run
2932+
/// for this sender. Bots bypass L3 — mirroring [`is_denied_user`]'s `!is_bot`
2933+
/// bypass — because bot admission is a separate concern (`allow_bot_messages` +
2934+
/// `trusted_bot_ids`), and L3 (`allowed_users`) is a human-identity allowlist.
2935+
/// Running L3 on bots would wrongly deny mode-admitted/trusted bots when
2936+
/// `allow_all_users=false` (multi-agent). See PR #1270 review.
2937+
fn l3_gate_applies(is_bot: bool) -> bool {
2938+
!is_bot
2939+
}
2940+
29012941
/// Returns `true` if a bot message should bypass the `allow_bot_messages` mode check.
29022942
/// A trusted bot that @mentions this bot is treated the same as a human @mention —
29032943
/// it can pull the bot into a thread regardless of the `allow_bot_messages` setting.
@@ -3904,6 +3944,15 @@ mod tests {
39043944
assert!(!is_denied_user(true, false, &allowed, 999));
39053945
}
39063946

3947+
#[test]
3948+
fn l3_gate_skips_bots_admits_humans() {
3949+
// Regression guard (#1270 F1): the shared L3 identity gate must NOT run
3950+
// for bots — mirrors is_denied_user's !is_bot bypass. Otherwise trusted /
3951+
// mode-admitted bots would be denied when allow_all_users=false.
3952+
assert!(!l3_gate_applies(true)); // bot → gate skipped
3953+
assert!(l3_gate_applies(false)); // human → gate applies
3954+
}
3955+
39073956
// --- Trusted bot mention bypass tests ---
39083957
// A trusted bot @mentioning this bot bypasses allow_bot_messages mode,
39093958
// treating the mention the same as a human @mention.

src/main.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,6 +293,26 @@ async fn main() -> anyhow::Result<()> {
293293
),
294294
);
295295
}
296+
297+
// Discord: gate L3 (identity) only via the shared gate. Discord's L2 is
298+
// richer than the flat allowed_channels model (threads are admitted by
299+
// *parent* channel, DMs by allow_dm), so we leave channel/DM enforcement
300+
// in the adapter and set L2 open here. L3 mirrors the resolved
301+
// [discord].allow_all_users/allowed_users, so the gate agrees with
302+
// Discord's existing user check (behavior-preserving). L2 + dispatch-path
303+
// privatization for Discord follow once the richer channel model lands.
304+
if let Some(d) = &cfg.discord {
305+
reg.insert(
306+
"discord",
307+
TrustConfig::new(
308+
Some(true), // L2 open — Discord's own channel/thread/DM logic still applies
309+
Vec::<String>::new(),
310+
Some(true),
311+
Some(config::resolve_allow_all(d.allow_all_users, &d.allowed_users)),
312+
d.allowed_users.clone(),
313+
),
314+
);
315+
}
296316
reg
297317
};
298318

0 commit comments

Comments
 (0)