diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index d9cbd8d2f..ad1f25ecd 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -443,6 +443,10 @@ pub struct AdapterRouter { workspace_aliases: std::collections::HashMap, /// Bot home directory (security boundary for workspace directives). bot_home: std::path::PathBuf, + /// Per-platform trust gate (L2 scope + L3 identity). Populated via + /// [`AdapterRouter::with_trust`]; empty default = deny-all per platform + /// (only consulted by paths wired to the gate — currently the gateway path). + trust: crate::trust::PlatformTrustConfigs, } impl AdapterRouter { @@ -472,9 +476,32 @@ impl AdapterRouter { liveness_check_interval: std::time::Duration::from_secs(liveness_check_secs), workspace_aliases, bot_home, + trust: crate::trust::PlatformTrustConfigs::default(), } } + /// Attach the per-platform trust registry (builder style, before `Arc`-wrapping). + /// Keeps `new()`'s signature stable across its many call sites. + pub fn with_trust(mut self, trust: crate::trust::PlatformTrustConfigs) -> Self { + self.trust = trust; + self + } + + /// The single ingress trust gate: evaluate L2 (scope) + L3 (identity) for an + /// inbound message. This is the long-term choke point — dispatch paths should + /// only be reachable after an `Allow` here. Returns the [`Decision`] so the + /// caller can echo on `DenyIdentity` (request-access UX) vs silently drop on + /// `DenyScope`. + pub fn gate_incoming( + &self, + platform: &str, + channel_id: &str, + is_dm: bool, + sender_id: &str, + ) -> crate::trust::Decision { + self.trust.decide(platform, channel_id, is_dm, sender_id) + } + /// Access the underlying session pool (e.g. for config option queries). pub fn pool(&self) -> &Arc { &self.pool diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index 6495b1b99..4a8a29635 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -1147,12 +1147,16 @@ pub async fn process_gateway_event( let event: GatewayEvent = serde_json::from_str(event_json) .map_err(|e| anyhow::anyhow!("invalid gateway event JSON: {e}"))?; - // Shared filter logic + // Structural gating (bot filter + @mention) stays in should_skip_event. + // L2 (channel) + L3 (identity) are now enforced by the shared ingress gate + // (`router.gate_incoming`) below, so we neuter should_skip_event's channel/user + // checks here by passing allow-all for them. + let no_ids: HashSet = HashSet::new(); let filter = EventFilterParams { - allow_all_channels: ctx.allow_all_channels, - allowed_channels: &ctx.allowed_channels, - allow_all_users: ctx.allow_all_users, - allowed_users: &ctx.allowed_users, + allow_all_channels: true, + allowed_channels: &no_ids, + allow_all_users: true, + allowed_users: &no_ids, allow_bot_messages: ctx.allow_bot_messages, trusted_bot_ids: &ctx.trusted_bot_ids, bot_username: ctx.bot_username.as_deref(), @@ -1161,6 +1165,27 @@ pub async fn process_gateway_event( return Ok(false); } + // Shared ingress trust gate (L2 scope + L3 identity), keyed by platform. + // Phase 1: `is_dm = false` preserves today's behavior where gateway DMs are + // evaluated against the channel allowlist like any other channel (the + // `allow_dm` surface semantics arrive with the per-platform trust flip). + // TODO(phase-2): derive is_dm from the event/ChannelRef carrier so the + // `allow_dm` L2 surface can be enforced and tested for gateway platforms. + 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); + } + tracing::info!( platform = %event.platform, sender = %event.sender.name, diff --git a/src/main.rs b/src/main.rs index 33bd88a27..25cc3db3f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -256,21 +256,64 @@ async fn main() -> anyhow::Result<()> { info!(model = %cfg.stt.model, base_url = %cfg.stt.base_url, "STT enabled"); } - let router = Arc::new(AdapterRouter::new( - pool.clone(), - cfg.reactions, - cfg.markdown.tables, - cfg.pool.prompt_hard_timeout_secs, - cfg.pool.liveness_check_secs, - cfg.workspace.aliases, - std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| { - tracing::warn!( - "HOME environment variable is not set — falling back to /tmp as bot_home. \ - This weakens the workspace security boundary." + // Build the per-platform trust registry for the gateway platforms from the + // same GATEWAY_* env the unified bridge uses (behavior-preserving: defaults + // allow-all, matching today's should_skip_event). L2/L3 enforcement moves to + // the router's ingress gate; should_skip_event keeps only bot + @mention + // gating for the unified path. Discord/Slack are wired in a later PR. + let gateway_trust = { + use openab_core::trust::{PlatformTrustConfigs, TrustConfig}; + let env_bool = |k: &str, default: bool| { + std::env::var(k) + .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(default) + }; + let env_set = |k: &str| -> Vec { + std::env::var(k) + .unwrap_or_default() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + }; + 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); + let allowed_users = env_set("GATEWAY_ALLOWED_USERS"); + let mut reg = PlatformTrustConfigs::new(); + for platform in ["telegram", "line", "feishu", "wecom", "googlechat", "teams"] { + reg.insert( + platform, + TrustConfig::new( + Some(allow_all_channels), + allowed_channels.clone(), + None, // allow_dm unused in Phase 1 (is_dm passed as false) + Some(allow_all_users), + allowed_users.clone(), + ), ); - "/tmp".into() - })), - )); + } + reg + }; + + let router = Arc::new( + AdapterRouter::new( + pool.clone(), + cfg.reactions, + cfg.markdown.tables, + cfg.pool.prompt_hard_timeout_secs, + cfg.pool.liveness_check_secs, + cfg.workspace.aliases, + std::path::PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| { + tracing::warn!( + "HOME environment variable is not set — falling back to /tmp as bot_home. \ + This weakens the workspace security boundary." + ); + "/tmp".into() + })), + ) + .with_trust(gateway_trust), + ); // Shutdown signal for Slack adapter let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(false);