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
27 changes: 27 additions & 0 deletions crates/openab-core/src/adapter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,10 @@ pub struct AdapterRouter {
workspace_aliases: std::collections::HashMap<String, String>,
/// 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 {
Expand Down Expand Up @@ -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<SessionPool> {
&self.pool
Expand Down
35 changes: 30 additions & 5 deletions crates/openab-core/src/gateway.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> = 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(),
Expand All @@ -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,
Expand Down
71 changes: 57 additions & 14 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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);
Expand Down
Loading