From d9d8615706ada2ee83f10612e2ca92fe41abc6ae Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 18:16:09 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat(trust):=20Phase=200=20=E2=80=94=20shar?= =?UTF-8?q?ed=20TrustConfig=20+=20PlatformTrustConfigs=20(additive)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defines the shared L2 (scope) + L3 (identity) trust types and pure decision function per the identity-trust-none ADR (#1264). Purely additive: not yet wired into AdapterRouter, changes no runtime behavior. - TrustConfig: L2 (allow_all_channels/allowed_channels/allow_dm, default open) + L3 (allow_all_users/allowed_users, default deny-all) - Decision enum (Allow / DenyScope / DenyIdentity) — only DenyIdentity echoes (request-access UX) - PlatformTrustConfigs registry keyed by platform() (no cross-platform ID bleed) - 11 unit tests covering the L2×L3×DM decision matrix Wiring + removing scattered per-adapter checks lands in Phase 1; the trust-none default flip lands in Phase 3. Refs #1264 --- crates/openab-core/src/lib.rs | 1 + crates/openab-core/src/trust.rs | 270 ++++++++++++++++++++++++++++++++ 2 files changed, 271 insertions(+) create mode 100644 crates/openab-core/src/trust.rs diff --git a/crates/openab-core/src/lib.rs b/crates/openab-core/src/lib.rs index b6add32e8..411b90779 100644 --- a/crates/openab-core/src/lib.rs +++ b/crates/openab-core/src/lib.rs @@ -21,6 +21,7 @@ pub mod secrets; pub mod setup; pub mod stt; pub mod timestamp; +pub mod trust; #[cfg(feature = "discord")] pub mod discord; diff --git a/crates/openab-core/src/trust.rs b/crates/openab-core/src/trust.rs new file mode 100644 index 000000000..7a1401df3 --- /dev/null +++ b/crates/openab-core/src/trust.rs @@ -0,0 +1,270 @@ +//! Shared trust model — the L2 (scope) + L3 (identity) layers of the trust +//! pyramid (see ADR: identity trust-none default & trust pyramid). +//! +//! Phase 0 (this module) is **purely additive**: it defines the shared +//! [`TrustConfig`] / [`PlatformTrustConfigs`] types and the pure decision +//! function. It is NOT yet wired into `AdapterRouter::handle_message()` and does +//! not change any runtime behavior. Wiring (and removing the scattered per-adapter +//! checks) lands in Phase 1; the trust-none default flip lands in Phase 3. +//! +//! Layering recap: +//! - **L2 — scope control** (`allow_all_channels` / `allowed_channels` / `allow_dm`): +//! which conversation *surfaces* the bot engages in. NOT a security boundary — +//! the platform already enforces channel membership. **Default: open.** +//! - **L3 — identity trust** (`allow_all_users` / `allowed_users`): which *human* +//! senders may trigger the agent. The security gate. **Default: deny-all.** +//! +//! Bot admission (`trusted_bot_ids` / `allow_bot_messages`) and trigger semantics +//! (@mention, multibot, role triggers) are intentionally NOT part of this model — +//! they stay in the adapters. + +use std::collections::HashSet; + +/// Outcome of evaluating the trust gate for a single inbound message. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// Allowed — dispatch to the agent. + Allow, + /// Denied at L2 (scope): the conversation surface is out of scope. + /// No echo — the sender did not pass an authorization check, the surface + /// simply isn't enabled. + DenyScope, + /// Denied at L3 (identity): the surface is in scope but the sender is not + /// trusted. The caller should echo the sender their ID (request-access UX). + DenyIdentity, +} + +impl Decision { + /// Whether the router should echo the sender their ID on this decision. + /// Only L3 (identity) denials get the request-access echo. + pub fn should_echo(self) -> bool { + matches!(self, Decision::DenyIdentity) + } + + pub fn is_allowed(self) -> bool { + matches!(self, Decision::Allow) + } +} + +/// Per-platform trust configuration (L2 scope + L3 identity). +/// +/// Construct via [`TrustConfig::new`], which applies the ADR defaults: +/// **L2 open, L3 deny-all**. +#[derive(Debug, Clone)] +pub struct TrustConfig { + // --- L2: scope control (NOT security). Default open. --- + pub allow_all_channels: bool, + pub allowed_channels: HashSet, + pub allow_dm: bool, + // --- L3: identity trust (security gate). Default deny-all. --- + pub allow_all_users: bool, + pub allowed_users: HashSet, +} + +impl Default for TrustConfig { + /// L2 open, L3 deny-all — the ADR's default posture. + fn default() -> Self { + Self { + allow_all_channels: true, + allowed_channels: HashSet::new(), + allow_dm: true, + allow_all_users: false, + allowed_users: HashSet::new(), + } + } +} + +impl TrustConfig { + /// Build from raw config values, applying defaults for unset flags: + /// - L2 `allow_all_channels` / `allow_dm` default **true** (open) + /// - L3 `allow_all_users` defaults **false** (deny-all) + /// + /// NOTE: this is the ADR-correct (Phase 3) resolution. Phase 0/1 do not call + /// this at runtime, so shipping it here changes no behavior yet. + pub fn new( + allow_all_channels: Option, + allowed_channels: impl IntoIterator, + allow_dm: Option, + allow_all_users: Option, + allowed_users: impl IntoIterator, + ) -> Self { + Self { + allow_all_channels: allow_all_channels.unwrap_or(true), + allowed_channels: allowed_channels.into_iter().collect(), + allow_dm: allow_dm.unwrap_or(true), + allow_all_users: allow_all_users.unwrap_or(false), + allowed_users: allowed_users.into_iter().collect(), + } + } + + /// L2: is this conversation surface in scope? + /// DMs are gated by `allow_dm`; channels/groups by the channel allowlist. + pub fn surface_allowed(&self, channel_id: &str, is_dm: bool) -> bool { + if is_dm { + return self.allow_dm; + } + self.allow_all_channels || self.allowed_channels.contains(channel_id) + } + + /// L3: is this (human) identity trusted? + pub fn identity_allowed(&self, sender_id: &str) -> bool { + self.allow_all_users || self.allowed_users.contains(sender_id) + } + + /// Evaluate L2 then L3 and return the [`Decision`]. + pub fn decide(&self, channel_id: &str, is_dm: bool, sender_id: &str) -> Decision { + if !self.surface_allowed(channel_id, is_dm) { + return Decision::DenyScope; + } + if !self.identity_allowed(sender_id) { + return Decision::DenyIdentity; + } + Decision::Allow + } +} + +/// Registry of per-platform [`TrustConfig`], keyed by `platform()` name +/// (e.g. "discord", "slack", "telegram"). Keying by platform prevents +/// cross-platform ID bleed (a Telegram UID can never satisfy a LINE allowlist). +#[derive(Debug, Clone, Default)] +pub struct PlatformTrustConfigs { + map: std::collections::HashMap, + default: TrustConfig, +} + +impl PlatformTrustConfigs { + pub fn new() -> Self { + Self::default() + } + + /// Register a platform's trust config. + pub fn insert(&mut self, platform: impl Into, cfg: TrustConfig) { + self.map.insert(platform.into(), cfg); + } + + /// Get the trust config for a platform, or the default (L2 open / L3 deny-all) + /// when the platform has no explicit configuration. + pub fn get(&self, platform: &str) -> &TrustConfig { + self.map.get(platform).unwrap_or(&self.default) + } + + /// Convenience: evaluate the gate for a platform in one call. + pub fn decide( + &self, + platform: &str, + channel_id: &str, + is_dm: bool, + sender_id: &str, + ) -> Decision { + self.get(platform).decide(channel_id, is_dm, sender_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn cfg() -> TrustConfig { + // L2 open, explicit allowed channel; L3 with one allowed user. + TrustConfig::new( + None, // allow_all_channels → true + ["chan-1".to_string()], + None, // allow_dm → true + None, // allow_all_users → false (deny) + ["user-1".to_string()], + ) + } + + #[test] + fn defaults_are_l2_open_l3_deny() { + let c = TrustConfig::default(); + assert!(c.allow_all_channels); + assert!(c.allow_dm); + assert!(!c.allow_all_users); + assert!(c.allowed_users.is_empty()); + } + + #[test] + fn allowed_user_in_scope_channel_is_allowed() { + assert_eq!(cfg().decide("any-channel", false, "user-1"), Decision::Allow); + } + + #[test] + fn untrusted_user_in_channel_denied_identity() { + assert_eq!( + cfg().decide("any-channel", false, "stranger"), + Decision::DenyIdentity + ); + } + + #[test] + fn untrusted_user_in_dm_denied_identity_not_scope() { + // DM surface open by default → reaches L3 → identity deny (echo path). + assert_eq!(cfg().decide("dm-chan", true, "stranger"), Decision::DenyIdentity); + } + + #[test] + fn allowed_user_in_dm_is_allowed() { + assert_eq!(cfg().decide("dm-chan", true, "user-1"), Decision::Allow); + } + + #[test] + fn scope_denied_when_channel_not_listed_and_not_open() { + let c = TrustConfig::new( + Some(false), // allow_all_channels closed + ["chan-1".to_string()], + Some(false), // allow_dm closed + Some(true), // allow_all_users (irrelevant — L2 fails first) + std::iter::empty(), + ); + // Out-of-scope channel → DenyScope (no echo), even though L3 would allow. + assert_eq!(c.decide("other-chan", false, "anyone"), Decision::DenyScope); + // DM closed → DenyScope. + assert_eq!(c.decide("dm", true, "anyone"), Decision::DenyScope); + // In-scope channel → L3 allows (allow_all_users). + assert_eq!(c.decide("chan-1", false, "anyone"), Decision::Allow); + } + + #[test] + fn allow_all_users_opens_l3() { + let c = TrustConfig::new(None, std::iter::empty(), None, Some(true), std::iter::empty()); + assert_eq!(c.decide("c", false, "anyone"), Decision::Allow); + } + + #[test] + fn dm_closed_denies_scope_even_for_allowed_user() { + let c = TrustConfig::new(None, std::iter::empty(), Some(false), None, ["user-1".to_string()]); + // allowed user, but DM surface disabled → DenyScope (no echo). + assert_eq!(c.decide("dm", true, "user-1"), Decision::DenyScope); + // same user in a channel (L2 open) → Allow. + assert_eq!(c.decide("c", false, "user-1"), Decision::Allow); + } + + #[test] + fn decision_echo_semantics() { + assert!(Decision::DenyIdentity.should_echo()); + assert!(!Decision::DenyScope.should_echo()); + assert!(!Decision::Allow.should_echo()); + assert!(Decision::Allow.is_allowed()); + } + + #[test] + fn registry_returns_default_for_unknown_platform() { + let reg = PlatformTrustConfigs::new(); + // unknown platform → default (L3 deny-all) → stranger denied identity. + assert_eq!(reg.decide("mars", "c", false, "stranger"), Decision::DenyIdentity); + } + + #[test] + fn registry_uses_registered_platform_config() { + let mut reg = PlatformTrustConfigs::new(); + reg.insert( + "telegram", + TrustConfig::new(None, std::iter::empty(), None, None, ["123".to_string()]), + ); + assert_eq!(reg.decide("telegram", "c", false, "123"), Decision::Allow); + assert_eq!(reg.decide("telegram", "c", false, "999"), Decision::DenyIdentity); + // unregistered platform still gets deny-all default. + assert_eq!(reg.decide("discord", "c", false, "123"), Decision::DenyIdentity); + } +} From 3c507c393cecc155783c6513c3f372f833ad43d5 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 18:21:56 -0400 Subject: [PATCH 2/3] docs(trust): add compact decision-flow diagram to decide() rustdoc --- crates/openab-core/src/trust.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/crates/openab-core/src/trust.rs b/crates/openab-core/src/trust.rs index 7a1401df3..3d566105d 100644 --- a/crates/openab-core/src/trust.rs +++ b/crates/openab-core/src/trust.rs @@ -111,7 +111,16 @@ impl TrustConfig { self.allow_all_users || self.allowed_users.contains(sender_id) } - /// Evaluate L2 then L3 and return the [`Decision`]. + /// Evaluate L2 (scope) then L3 (identity) and return the [`Decision`]: + /// + /// ```text + /// surface_allowed? ──no──▶ DenyScope (silent) + /// │ yes + /// identity_allowed? ──no──▶ DenyIdentity (echo UID) + /// │ yes + /// ▼ + /// Allow + /// ``` pub fn decide(&self, channel_id: &str, is_dm: bool, sender_id: &str) -> Decision { if !self.surface_allowed(channel_id, is_dm) { return Decision::DenyScope; From ea6514866b5276a5bc920ddaf70fb978ba98dcab Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 18:34:07 -0400 Subject: [PATCH 3/3] fix(trust): address #1266 review (F2/F3/F6/F7 + F5 doc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F2: #[non_exhaustive] on Decision (avoid future semver break) - F3: reword DenyScope doc — scope control, not an authorization failure - F6: normalize platform keys to lowercase (case-insensitive registry) - F7: empty sender_id is never identity-allowed (fail-closed, even under allow_all_users) - F5: document new() as canonical constructor; allow_all_* takes precedence - add 3 tests (empty-sender, case-insensitive registry) --- crates/openab-core/src/trust.rs | 57 ++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 8 deletions(-) diff --git a/crates/openab-core/src/trust.rs b/crates/openab-core/src/trust.rs index 3d566105d..603b564fc 100644 --- a/crates/openab-core/src/trust.rs +++ b/crates/openab-core/src/trust.rs @@ -21,13 +21,17 @@ use std::collections::HashSet; /// Outcome of evaluating the trust gate for a single inbound message. +/// +/// `#[non_exhaustive]` because later phases may add variants (e.g. a +/// rate-limited/throttled echo state); callers must include a `_` arm. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum Decision { /// Allowed — dispatch to the agent. Allow, - /// Denied at L2 (scope): the conversation surface is out of scope. - /// No echo — the sender did not pass an authorization check, the surface - /// simply isn't enabled. + /// Denied at L2 (scope): the bot is not configured to operate on this + /// conversation surface. This is **scope control, not an authorization + /// failure** (L2 is not a security boundary) — so it is silent (no echo). DenyScope, /// Denied at L3 (identity): the surface is in scope but the sender is not /// trusted. The caller should echo the sender their ID (request-access UX). @@ -49,7 +53,11 @@ impl Decision { /// Per-platform trust configuration (L2 scope + L3 identity). /// /// Construct via [`TrustConfig::new`], which applies the ADR defaults: -/// **L2 open, L3 deny-all**. +/// **L2 open, L3 deny-all**. Fields are public for cross-crate construction +/// (the binary builds the registry from config), but `new()` is the canonical +/// constructor. "Inconsistent" combinations are benign by precedence: an +/// `allow_all_*` flag always wins, so e.g. `allow_all_channels = true` with a +/// non-empty `allowed_channels` simply ignores the list. #[derive(Debug, Clone)] pub struct TrustConfig { // --- L2: scope control (NOT security). Default open. --- @@ -107,7 +115,14 @@ impl TrustConfig { } /// L3: is this (human) identity trusted? + /// + /// An empty `sender_id` (e.g. a system/webhook message with no human author) + /// is **never** identity-allowed — fail-closed, even under `allow_all_users`, + /// since an absent identity cannot be a trusted user. pub fn identity_allowed(&self, sender_id: &str) -> bool { + if sender_id.is_empty() { + return false; + } self.allow_all_users || self.allowed_users.contains(sender_id) } @@ -146,15 +161,19 @@ impl PlatformTrustConfigs { Self::default() } - /// Register a platform's trust config. + /// Register a platform's trust config. The platform key is normalized to + /// lowercase so a case mismatch with `adapter.platform()` can't silently + /// fall back to the deny-all default. pub fn insert(&mut self, platform: impl Into, cfg: TrustConfig) { - self.map.insert(platform.into(), cfg); + self.map.insert(platform.into().to_lowercase(), cfg); } /// Get the trust config for a platform, or the default (L2 open / L3 deny-all) - /// when the platform has no explicit configuration. + /// when the platform has no explicit configuration. Lookup is case-insensitive. pub fn get(&self, platform: &str) -> &TrustConfig { - self.map.get(platform).unwrap_or(&self.default) + self.map + .get(&platform.to_lowercase()) + .unwrap_or(&self.default) } /// Convenience: evaluate the gate for a platform in one call. @@ -276,4 +295,26 @@ mod tests { // unregistered platform still gets deny-all default. assert_eq!(reg.decide("discord", "c", false, "123"), Decision::DenyIdentity); } + + #[test] + fn empty_sender_is_never_identity_allowed() { + // Even with allow_all_users = true, an empty sender_id fails closed. + let open = TrustConfig::new(None, std::iter::empty(), None, Some(true), std::iter::empty()); + assert!(!open.identity_allowed("")); + assert_eq!(open.decide("c", false, ""), Decision::DenyIdentity); + // non-empty still allowed under allow_all_users. + assert_eq!(open.decide("c", false, "anyone"), Decision::Allow); + } + + #[test] + fn registry_lookup_is_case_insensitive() { + let mut reg = PlatformTrustConfigs::new(); + reg.insert( + "Telegram", + TrustConfig::new(None, std::iter::empty(), None, None, ["123".to_string()]), + ); + // mixed-case platform() value resolves to the same config. + assert_eq!(reg.decide("telegram", "c", false, "123"), Decision::Allow); + assert_eq!(reg.decide("TELEGRAM", "c", false, "123"), Decision::Allow); + } }