From f9b4da982dc0e5a17e692a4f52e916ec67f48ea7 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Wed, 1 Jul 2026 08:34:44 -0400 Subject: [PATCH 1/2] feat(trust): gateway echo-on-deny (Phase 2) + deny-all default (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Phase 2: on Decision::DenyIdentity, echo the sender their ID via adapter.send_message so they can request access (request-access UX). Throttled to 1 echo per (platform,sender) per 5min (LazyLock map) to prevent amplification. DenyScope stays silent (not a security boundary). - Phase 3 (gateway): flip GATEWAY_ALLOW_ALL_USERS default true→false, so gateway L3 is trust-none by default. L2 (channels) stays open. Admit via GATEWAY_ALLOWED_USERS or GATEWAY_ALLOW_ALL_USERS=true. Ships in pre-beta (self-use). emilie unaffected (own image + WS path, which doesn't use this registry/echo). Discord/Slack registry entries unchanged (still behavior-preserving allow-all). Refs #1264 #1269 --- crates/openab-core/src/gateway.rs | 73 ++++++++++++++++++++++++++----- src/main.rs | 5 ++- 2 files changed, 67 insertions(+), 11 deletions(-) diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 4a8a29635..41a259a62 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1140,6 +1140,28 @@ pub struct GatewayEventContext { /// /// This is the core event-handling logic extracted from the WebSocket handler, /// made available for the unified binary to call directly from axum webhook handlers. +/// Throttle for request-access echoes: at most one echo per (platform, sender) +/// per [`ECHO_WINDOW`], to prevent an untrusted spammer from being amplified by +/// the bot's replies. +static ECHO_THROTTLE: std::sync::LazyLock< + std::sync::Mutex>, +> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new())); + +const ECHO_WINDOW: std::time::Duration = std::time::Duration::from_secs(300); + +/// Returns true if an echo to `key` is allowed now (and records the timestamp). +fn echo_allowed(key: &str) -> bool { + let now = std::time::Instant::now(); + let mut map = ECHO_THROTTLE.lock().unwrap(); + match map.get(key) { + Some(prev) if now.duration_since(*prev) < ECHO_WINDOW => false, + _ => { + map.insert(key.to_string(), now); + true + } + } +} + pub async fn process_gateway_event( event_json: &str, ctx: &GatewayEventContext, @@ -1174,16 +1196,47 @@ pub async fn process_gateway_event( let decision = ctx.router .gate_incoming(&event.platform, &event.channel.id, false, &event.sender.id); - if !decision.is_allowed() { - tracing::info!( - platform = %event.platform, - sender = %event.sender.id, - channel = %event.channel.id, - ?decision, - "gateway event denied by trust gate" - ); - // Phase 2 will echo the sender their ID on Decision::DenyIdentity. - return Ok(false); + match decision { + crate::trust::Decision::Allow => {} + crate::trust::Decision::DenyIdentity => { + // L3 identity deny → echo the sender their ID so they can request + // access (throttled to avoid amplification). Bots never reach here + // (should_skip_event handles bot admission; L3 is human-only). + tracing::info!( + platform = %event.platform, + sender = %event.sender.id, + channel = %event.channel.id, + "gateway event denied (identity); echoing request-access" + ); + let throttle_key = format!("{}:{}", event.platform, event.sender.id); + if echo_allowed(&throttle_key) { + let echo_channel = ChannelRef { + platform: event.platform.clone(), + channel_id: event.channel.id.clone(), + thread_id: event.channel.thread_id.clone(), + parent_id: None, + origin_event_id: Some(event.event_id.clone()), + }; + let echo = format!( + "⚠️ You are not on this bot's trusted list.\nYour ID: {}\nAsk the admin to add it to allowed_users.", + event.sender.id + ); + let _ = ctx.adapter.send_message(&echo_channel, &echo).await; + } + return Ok(false); + } + // DenyScope (and any future variant) → silent drop (scope is not a + // security boundary; no request-access echo). + _ => { + tracing::info!( + platform = %event.platform, + sender = %event.sender.id, + channel = %event.channel.id, + ?decision, + "gateway event denied (scope); silent" + ); + return Ok(false); + } } tracing::info!( diff --git a/src/main.rs b/src/main.rs index e96bfa5e6..b7daf5656 100644 --- a/src/main.rs +++ b/src/main.rs @@ -278,7 +278,10 @@ async fn main() -> anyhow::Result<()> { }; let allow_all_channels = env_bool("GATEWAY_ALLOW_ALL_CHANNELS", true); let allowed_channels = env_set("GATEWAY_ALLOWED_CHANNELS"); - let allow_all_users = env_bool("GATEWAY_ALLOW_ALL_USERS", true); + // L3 identity: trust-none by default (Phase 3). Was `true` in #1267 + // (behavior-preserving); now defaults deny-all — set GATEWAY_ALLOW_ALL_USERS=true + // or list GATEWAY_ALLOWED_USERS to admit senders. L2 (channels) stays open. + let allow_all_users = env_bool("GATEWAY_ALLOW_ALL_USERS", false); let allowed_users = env_set("GATEWAY_ALLOWED_USERS"); let mut reg = PlatformTrustConfigs::new(); for platform in ["telegram", "line", "feishu", "wecom", "googlechat", "teams"] { From f60470dec23558fc23acee760803aa304b29408b Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Wed, 1 Jul 2026 08:35:20 -0400 Subject: [PATCH 2/2] test(trust): echo throttle regression test --- crates/openab-core/src/gateway.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 41a259a62..e238eb538 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1473,6 +1473,15 @@ mod tests { use super::*; use std::collections::HashSet; + #[test] + fn echo_allowed_throttles_repeat_within_window() { + // Unique key so we don't collide with other tests touching the global map. + let key = "test-platform:test-sender-echo-throttle"; + assert!(echo_allowed(key), "first echo should be allowed"); + assert!(!echo_allowed(key), "immediate repeat should be throttled"); + assert!(!echo_allowed(key), "still throttled within the window"); + } + fn make_event(is_bot: bool, sender_id: &str, channel_id: &str, channel_type: &str, thread_id: Option<&str>, mentions: Vec<&str>) -> GatewayEvent { serde_json::from_value(serde_json::json!({ "schema": "openab.gateway.event.v1",