Skip to content

Commit fc65e52

Browse files
authored
Allow configured tools in shared Slack channels (#1238)
1 parent bab41dd commit fc65e52

5 files changed

Lines changed: 128 additions & 18 deletions

File tree

crates/config/src/template.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -900,6 +900,8 @@ port = {port} # Port number (auto-generated for this i
900900
# dm_policy = "allowlist"
901901
# allowlist = []
902902
# operators = [] # Exact sender IDs allowed to run privileged commands; empty means nobody.
903+
# untrusted_audience = "public" # "trusted" makes MCP and other trusted-audience tools eligible.
904+
# untrusted_tools = "deny_all" # "policy" lets configured policy layers decide.
903905
# thread_replies = true
904906
# stream_mode = "edit_in_place" # use "native" for Slack live text and tool task cards
905907
# ack_reactions = true # 👀 on receipt, phase emoji while working, ✅/❌ on completion

crates/slack/src/config.rs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::collections::HashMap;
22

33
use {
44
moltis_channels::{
5-
config_view::ChannelConfigView,
5+
config_view::{ChannelConfigView, UntrustedAudience, UntrustedTools},
66
gating::{DmPolicy, GroupPolicy, MentionMode},
77
},
88
moltis_common::secret_serde,
@@ -104,6 +104,16 @@ pub struct SlackAccountConfig {
104104
#[serde(default)]
105105
pub channel_allowlist: Vec<String>,
106106

107+
/// Tool audience ceiling for turns outside an operator direct chat
108+
/// (default: `public`).
109+
#[serde(default)]
110+
pub untrusted_audience: UntrustedAudience,
111+
112+
/// Name policy for turns outside an operator direct chat
113+
/// (default: `deny_all`).
114+
#[serde(default)]
115+
pub untrusted_tools: UntrustedTools,
116+
107117
/// Default model for this account.
108118
#[serde(skip_serializing_if = "Option::is_none")]
109119
pub model: Option<String>,
@@ -178,6 +188,8 @@ impl std::fmt::Debug for SlackAccountConfig {
178188
.field("allowlist", &self.allowlist)
179189
.field("operators", &self.operators)
180190
.field("channel_allowlist", &self.channel_allowlist)
191+
.field("untrusted_audience", &self.untrusted_audience)
192+
.field("untrusted_tools", &self.untrusted_tools)
181193
.field("model", &self.model)
182194
.field("model_provider", &self.model_provider)
183195
.field("agent_id", &self.agent_id)
@@ -210,6 +222,8 @@ impl Default for SlackAccountConfig {
210222
allowlist: Vec::new(),
211223
operators: Vec::new(),
212224
channel_allowlist: Vec::new(),
225+
untrusted_audience: UntrustedAudience::default(),
226+
untrusted_tools: UntrustedTools::default(),
213227
model: None,
214228
model_provider: None,
215229
agent_id: None,
@@ -241,6 +255,14 @@ impl ChannelConfigView for SlackAccountConfig {
241255
&self.channel_allowlist
242256
}
243257

258+
fn untrusted_audience(&self) -> UntrustedAudience {
259+
self.untrusted_audience
260+
}
261+
262+
fn untrusted_tools(&self) -> UntrustedTools {
263+
self.untrusted_tools
264+
}
265+
244266
fn dm_policy(&self) -> DmPolicy {
245267
self.dm_policy.clone()
246268
}
@@ -292,7 +314,7 @@ pub struct RedactedConfig<'a>(pub &'a SlackAccountConfig);
292314
impl Serialize for RedactedConfig<'_> {
293315
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
294316
let c = self.0;
295-
let mut count = 17; // always-present fields
317+
let mut count = 20; // always-present fields
296318
count += c.signing_secret.is_some() as usize;
297319
count += !c.reaction_trigger_emojis.is_empty() as usize;
298320
count += c.model.is_some() as usize;
@@ -314,6 +336,8 @@ impl Serialize for RedactedConfig<'_> {
314336
s.serialize_field("allowlist", &c.allowlist)?;
315337
s.serialize_field("operators", &c.operators)?;
316338
s.serialize_field("channel_allowlist", &c.channel_allowlist)?;
339+
s.serialize_field("untrusted_audience", &c.untrusted_audience)?;
340+
s.serialize_field("untrusted_tools", &c.untrusted_tools)?;
317341
if c.model.is_some() {
318342
s.serialize_field("model", &c.model)?;
319343
}
@@ -365,6 +389,8 @@ mod tests {
365389
assert!(cfg.group_allowlist().is_empty());
366390
assert_eq!(cfg.dm_policy(), DmPolicy::Allowlist);
367391
assert_eq!(cfg.group_policy(), GroupPolicy::Open);
392+
assert_eq!(cfg.untrusted_audience(), UntrustedAudience::Public);
393+
assert_eq!(cfg.untrusted_tools(), UntrustedTools::DenyAll);
368394
assert!(cfg.model().is_none());
369395
assert!(cfg.model_provider().is_none());
370396
}
@@ -479,6 +505,41 @@ mod tests {
479505
assert_eq!(redacted["ack_reactions"], serde_json::json!(false));
480506
}
481507

508+
#[test]
509+
fn untrusted_tool_ceiling_round_trips_and_redacts() {
510+
let cfg: SlackAccountConfig = serde_json::from_value(serde_json::json!({
511+
"bot_token": "xoxb-test",
512+
"app_token": "xapp-test",
513+
"untrusted_audience": "trusted",
514+
"untrusted_tools": "policy",
515+
}))
516+
.unwrap();
517+
518+
assert_eq!(cfg.untrusted_audience(), UntrustedAudience::Trusted);
519+
assert_eq!(cfg.untrusted_tools(), UntrustedTools::Policy);
520+
521+
let stored = serde_json::to_value(&cfg).unwrap();
522+
let round_tripped: SlackAccountConfig = serde_json::from_value(stored).unwrap();
523+
assert_eq!(
524+
round_tripped.untrusted_audience(),
525+
UntrustedAudience::Trusted
526+
);
527+
assert_eq!(round_tripped.untrusted_tools(), UntrustedTools::Policy);
528+
529+
let redacted = serde_json::to_value(RedactedConfig(&round_tripped)).unwrap();
530+
assert_eq!(redacted["untrusted_audience"], "trusted");
531+
assert_eq!(redacted["untrusted_tools"], "policy");
532+
}
533+
534+
#[test]
535+
fn invalid_untrusted_tool_ceiling_is_rejected() {
536+
let invalid_audience = serde_json::json!({ "untrusted_audience": "everyone" });
537+
assert!(serde_json::from_value::<SlackAccountConfig>(invalid_audience).is_err());
538+
539+
let invalid_tools = serde_json::json!({ "untrusted_tools": "allow_all" });
540+
assert!(serde_json::from_value::<SlackAccountConfig>(invalid_tools).is_err());
541+
}
542+
482543
#[test]
483544
fn connection_mode_events_api_round_trip() {
484545
let json = serde_json::json!({

docs/src/channels.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -273,10 +273,11 @@ current chat (`/attach`, `/sessions`, `/context`, `/insights`, `/peek`, `/btw`).
273273
New commands default to operator direct-chat only, so adding one is safe until
274274
it is deliberately reviewed.
275275

276-
Guest, shared-room, and unknown-topology channel turns receive no tools. The
277-
gateway applies both the registry's public audience ceiling and a deny-all name
278-
policy, so account, group, and per-sender tool policies cannot restore even a
279-
tool reviewed for other untrusted origins.
276+
By default, guest, shared-room, and unknown-topology channel turns receive no
277+
tools. The gateway applies both the registry's public audience ceiling and a
278+
deny-all name policy, so account, group, and per-sender tool policies cannot
279+
restore even a tool reviewed for other untrusted origins unless the channel
280+
account explicitly lifts that ceiling as described below.
280281

281282
Every normal turn in a shared room is untrusted, including turns sent by an
282283
operator, because the shared history contains messages from other people.
@@ -382,9 +383,9 @@ Untrusted tool restrictions stack with the per-channel tool policy
382383
policies can further restrict an operator DM, but cannot enable tools for a
383384
guest, shared room, or unknown chat: the untrusted ceiling denies everything
384385
before they are consulted. An account that raises its ceiling with
385-
`untrusted_audience` and `untrusted_tools` (WhatsApp only for now) hands the
386-
decision back to those policies for its own untrusted turns. The `/sh`
387-
shortcut stays restricted to operator direct chats either way.
386+
`untrusted_audience` and `untrusted_tools` hands the decision back to those
387+
policies for its own untrusted turns. Slack and WhatsApp support these settings.
388+
The `/sh` shortcut stays restricted to operator direct chats either way.
388389

389390
Grant eligibility for privileged access by adding the sender to `operators`;
390391
the sender must still use a proven direct chat.

docs/src/slack.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,7 +108,10 @@ offered = ["slack"]
108108
| `group_policy` | no | `"open"` | Who can talk to the bot in channels: `"open"`, `"allowlist"`, or `"disabled"` |
109109
| `mention_mode` | no | `"mention"` | When the bot responds in channels: `"always"`, `"mention"`, or `"none"` |
110110
| `allowlist` | no | `[]` | Slack user IDs allowed to DM the bot (when `dm_policy = "allowlist"`) |
111+
| `operators` | no | `[]` | Exact Slack user IDs eligible for privileged commands in proven direct chats. Empty means nobody. |
111112
| `channel_allowlist` | no | `[]` | Slack channel IDs allowed to interact with the bot |
113+
| `untrusted_audience` | no | `"public"` | Tool audience ceiling for turns outside an operator direct chat: `"public"` or `"trusted"` |
114+
| `untrusted_tools` | no | `"deny_all"` | Tool name policy for those turns: `"deny_all"`, or `"policy"` to let configured policy layers decide |
112115
| `otp_self_approval` | no | `true` | Enable OTP self-approval for non-allowlisted DM users |
113116
| `otp_cooldown_secs` | no | `300` | Cooldown after failed OTP attempts |
114117
| `model` | no || Override the default model for this channel |
@@ -143,6 +146,7 @@ dm_policy = "allowlist"
143146
group_policy = "open"
144147
mention_mode = "mention"
145148
allowlist = ["U0123456789", "U9876543210"]
149+
operators = ["U0123456789"]
146150
channel_allowlist = ["C0123456789"]
147151
otp_self_approval = true
148152
otp_cooldown_secs = 300
@@ -163,6 +167,40 @@ model = "claude-sonnet-4-20250514"
163167
model_provider = "anthropic"
164168
```
165169

170+
### Tools in Shared Channels
171+
172+
By default, Slack channels and DMs from non-operators receive no tools. To let
173+
those turns use tools, lift both parts of the untrusted-turn ceiling and use a
174+
tool policy to restrict what is available:
175+
176+
```toml
177+
[channels.slack.my-bot]
178+
untrusted_audience = "trusted"
179+
untrusted_tools = "policy"
180+
181+
[channels.slack.my-bot.tools.groups.channel]
182+
allow = ["linear_*"]
183+
184+
[channels.slack.my-bot.tools.groups.direct]
185+
allow = ["linear_*"]
186+
```
187+
188+
`untrusted_audience = "trusted"` makes MCP, WASM, and other trusted-audience
189+
tools eligible. `untrusted_tools = "policy"` removes the blanket name denial.
190+
Both settings are required for MCP tools, and without a restrictive policy the
191+
turn can reach every tool allowed by the remaining policy layers.
192+
193+
The ceiling applies to every turn outside an operator direct chat, so restrict
194+
both `channel` and `direct` when non-operator DMs are enabled. For accounts
195+
created in the web UI, Advanced Config can set the two ceiling fields, but
196+
database-backed `tools.groups` policies are not currently part of runtime
197+
policy resolution. Configure a restrictive global or provider policy before
198+
lifting the ceiling on a UI-managed account.
199+
200+
These settings do not expose `/sh`, privileged commands, or owner-private
201+
prompt context in shared rooms. Those remain restricted to operators in proven
202+
direct chats.
203+
166204
### Events API Mode
167205

168206
Set these Request URLs in your Slack app (replace `<id>` with the account ID you

docs/src/tool-policy.md

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,11 @@
33
Tool policies control which tools are available during a session. Before these
44
name-based layers run, Moltis applies the registry's host-owned audience
55
ceiling. Guest, shared-room, and unknown-topology channel turns receive no
6-
tools. Webhooks receive no tools by default and can explicitly opt into tools
7-
registered for public use. Policies may narrow but never widen these ceilings.
6+
tools by default. Supported channel accounts can explicitly lift that ceiling
7+
and hand tool selection back to these policy layers. Webhooks receive no tools
8+
by default and can explicitly opt into tools registered for public use. Policy
9+
layers may narrow or widen access only within the host-owned ceiling selected
10+
for the request.
811

912
Within the audience ceiling, policies use a layered system where each layer can
1013
restrict or widen access, and **deny always wins** — once a tool is denied at
@@ -111,9 +114,11 @@ allowed, and `exec`/`write_file` are explicitly denied. See
111114
## Layer 4 — Per-Channel Chat Type
112115

113116
Channel accounts can restrict tools by chat type (`private`, `group`,
114-
`channel`, etc.). Guest, shared-room, and unknown-topology turns already have a
115-
non-widenable deny-all policy. This layer is primarily useful for narrowing the
116-
tools available to an operator in a proven direct chat.
117+
`channel`, etc.). By default, guest, shared-room, and unknown-topology turns
118+
have a deny-all ceiling, so this layer primarily narrows tools available to an
119+
operator in a proven direct chat. Channels that support `untrusted_audience`
120+
and `untrusted_tools` can explicitly lift that ceiling and hand the decision
121+
back to this layer.
117122

118123
```toml
119124
[channels.telegram.my-bot.tools.groups.private]
@@ -126,8 +131,9 @@ handled by the `my-bot` account. Web UI sessions are unaffected.
126131
## Layer 5 — Per-Sender
127132

128133
Within a channel chat type, individual senders can receive name-policy
129-
overrides. Overrides cannot cross the deny-all ceiling applied to guests,
130-
shared rooms, and unknown chat kinds.
134+
overrides. Overrides cannot cross the default deny-all ceiling applied to
135+
guests, shared rooms, and unknown chat kinds unless the account explicitly
136+
lifts that ceiling.
131137

132138
```toml
133139
[channels.telegram.my-bot.tools.groups.private]
@@ -139,8 +145,10 @@ allow = ["*"]
139145

140146
Sender `123456` gets `allow = ["*"]`, which replaces the previous allow list.
141147
However, because **deny always accumulates**, the `exec` and `browser` denials
142-
from the chat-type layer still apply. The sender must also be a configured
143-
operator in a proven direct chat; a sender override alone never grants tools.
148+
from the chat-type layer still apply. By default, the sender must also be a
149+
configured operator in a proven direct chat; a sender override alone never
150+
grants tools. A supported channel account can instead opt its untrusted turns
151+
into policy-based access with `untrusted_audience` and `untrusted_tools`.
144152

145153
## Layer 6 — Sandbox
146154

0 commit comments

Comments
 (0)