Skip to content

Commit a51aeac

Browse files
vikng-devclaude
andauthored
fix(channels): make the untrusted-turn tool ceiling configurable (#1219)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 5bf3676 commit a51aeac

7 files changed

Lines changed: 213 additions & 12 deletions

File tree

crates/channels/src/config_view.rs

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,33 @@
1-
use crate::gating::{DmPolicy, GroupPolicy};
1+
use {
2+
crate::gating::{DmPolicy, GroupPolicy},
3+
serde::{Deserialize, Serialize},
4+
};
5+
6+
/// Tool audience ceiling for a turn that is not an operator in a proven direct
7+
/// chat. Defaults to the fail-closed [`Self::Public`].
8+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
9+
#[serde(rename_all = "snake_case")]
10+
pub enum UntrustedAudience {
11+
/// Only tools registered for the public audience are visible.
12+
#[default]
13+
Public,
14+
/// No audience ceiling. MCP, WASM, and other trusted tools become visible,
15+
/// leaving the name policy layers as the only limit on the turn.
16+
Trusted,
17+
}
18+
19+
/// Name policy applied to a turn that is not an operator in a proven direct
20+
/// chat. Defaults to the fail-closed [`Self::DenyAll`].
21+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
22+
#[serde(rename_all = "snake_case")]
23+
pub enum UntrustedTools {
24+
/// Deny every tool by name, on top of the audience ceiling.
25+
#[default]
26+
DenyAll,
27+
/// Add no name policy of its own and let the configured policy layers
28+
/// decide, the same way they decide for an operator direct chat.
29+
Policy,
30+
}
231

332
/// Typed read-only view of common channel account config fields.
433
///
@@ -27,6 +56,20 @@ pub trait ChannelConfigView: Send + Sync + std::fmt::Debug {
2756
&[]
2857
}
2958

59+
/// Tool audience ceiling for untrusted turns on this account. Raise it to
60+
/// let a known group reach MCP and other trusted tools, then narrow with
61+
/// the tool policy layers.
62+
fn untrusted_audience(&self) -> UntrustedAudience {
63+
UntrustedAudience::default()
64+
}
65+
66+
/// Name policy for untrusted turns on this account. [`UntrustedTools::Policy`]
67+
/// removes the blanket denial and leaves the configured policy layers in
68+
/// charge.
69+
fn untrusted_tools(&self) -> UntrustedTools {
70+
UntrustedTools::default()
71+
}
72+
3073
/// DM access policy.
3174
fn dm_policy(&self) -> DmPolicy;
3275

crates/gateway/src/channel_events.rs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ use {
1010
moltis_channels::{
1111
ChannelAckOutcome, ChannelAttachment, ChannelEvent, ChannelEventSink, ChannelMessageMeta,
1212
ChannelReplyTarget, Error as ChannelError, Result as ChannelResult, SavedChannelFile,
13+
config_view::{UntrustedAudience, UntrustedTools},
1314
},
1415
moltis_sessions::metadata::{SessionEntry, SqliteSessionMetadata},
1516
moltis_tools::approval::PendingApprovalView,
@@ -155,14 +156,51 @@ async fn resolve_sender_role(
155156
moltis_channels::operators::resolve_sender_role(sender_id, config.operators())
156157
}
157158

159+
/// Read the account's untrusted-turn ceiling. A missing registry or an unknown
160+
/// account falls back to the defaults, which are the unconfigured behaviour.
161+
async fn resolve_untrusted_ceiling(
162+
state: &Arc<GatewayState>,
163+
account_id: &str,
164+
) -> (UntrustedAudience, UntrustedTools) {
165+
let Some(ref registry) = state.services.channel_registry else {
166+
return Default::default();
167+
};
168+
let Some(config) = registry.account_config(account_id).await else {
169+
return Default::default();
170+
};
171+
(config.untrusted_audience(), config.untrusted_tools())
172+
}
173+
158174
/// Apply the fail-closed context used for every untrusted channel turn.
159175
///
160176
/// The audience ceiling excludes trusted tools, while the deny-all name policy
161177
/// also removes explicitly public tools. Configured policies may narrow this
162178
/// context further but cannot widen it.
163179
fn apply_untrusted_channel_context(params: &mut serde_json::Value) {
164-
params["_tool_audience"] = serde_json::json!("public");
165-
params["_tool_policy"] = serde_json::json!({ "deny": ["*"] });
180+
apply_untrusted_channel_context_with(
181+
params,
182+
UntrustedAudience::default(),
183+
UntrustedTools::default(),
184+
);
185+
}
186+
187+
/// Apply the untrusted channel context at the account's configured ceiling. The
188+
/// defaults reproduce [`apply_untrusted_channel_context`] exactly.
189+
///
190+
/// `_private_context` is deliberately not configurable: owner memory, profile
191+
/// and project context describe the owner rather than the conversation, so a
192+
/// room with other people in it never receives them.
193+
fn apply_untrusted_channel_context_with(
194+
params: &mut serde_json::Value,
195+
audience: UntrustedAudience,
196+
tools: UntrustedTools,
197+
) {
198+
if audience == UntrustedAudience::Public {
199+
params["_tool_audience"] = serde_json::json!("public");
200+
}
201+
if tools == UntrustedTools::DenyAll {
202+
params["_tool_policy"] = serde_json::json!({ "deny": ["*"] });
203+
}
166204
params["_private_context"] = serde_json::json!(false);
167205
}
168206

crates/gateway/src/channel_events/dispatch.rs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,21 @@ pub(in crate::channel_events) async fn dispatch_to_chat(
2626
let sender_role =
2727
resolve_sender_role(state, &reply_to.account_id, meta.sender_id.as_deref()).await;
2828

29+
let trusted_channel_turn = is_trusted_channel_turn(sender_role, &reply_to);
30+
let (untrusted_audience, untrusted_tools) =
31+
resolve_untrusted_ceiling(state, &reply_to.account_id).await;
32+
2933
// `/sh <cmd>` is deliberately not a registered channel command — it
3034
// falls through to the agent, which force-executes it. Stop it here
3135
// for guests, before it reaches the runner.
32-
let trusted_channel_turn = is_trusted_channel_turn(sender_role, &reply_to);
36+
//
37+
// This stays tied to `trusted_channel_turn` rather than to the
38+
// configured ceiling. `run_explicit_shell_command` takes `exec`
39+
// straight from the request registry, which the `[tools.policy]`
40+
// layers never touch: they are applied in `apply_runtime_tool_filters`
41+
// on the agent-run path, which `/sh` returns before reaching. So
42+
// letting the ceiling widen this guard would hand out an `exec` that
43+
// no `deny` can take back.
3344
if !trusted_channel_turn && moltis_agents::runner::explicit_shell_command(text).is_some() {
3445
warn!(
3546
account_id = %reply_to.account_id,
@@ -180,14 +191,15 @@ pub(in crate::channel_events) async fn dispatch_to_chat(
180191
"_native_channel_request": true,
181192
});
182193

183-
// Only an operator in a proven direct chat receives tools and private
184-
// context. This is the single condition on purpose: an earlier version
194+
// Only an operator in a proven direct chat is trusted outright; every
195+
// other turn gets a ceiling, whose tightness comes from the account
196+
// config. This is the single condition on purpose: an earlier version
185197
// also skipped the ceiling for anything that parsed as `/sh`, on the
186198
// assumption that the guard above had already rejected every untrusted
187199
// `/sh`. That made the ceiling depend on a rejection 60 lines away, so
188200
// narrowing that guard would have silently opened this one.
189201
if !trusted_channel_turn {
190-
apply_untrusted_channel_context(&mut params);
202+
apply_untrusted_channel_context_with(&mut params, untrusted_audience, untrusted_tools);
191203
}
192204

193205
// Carry this message's acknowledgment identity into the run so the

crates/gateway/src/channel_events/tests.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,86 @@ fn untrusted_channel_context_denies_every_tool_and_private_context() {
275275
assert_eq!(params["_private_context"], false);
276276
}
277277

278+
#[test]
279+
fn untrusted_ceiling_defaults_match_the_unconfigured_behaviour() {
280+
let mut configured = serde_json::json!({});
281+
let mut unconfigured = serde_json::json!({});
282+
283+
apply_untrusted_channel_context_with(
284+
&mut configured,
285+
UntrustedAudience::default(),
286+
UntrustedTools::default(),
287+
);
288+
apply_untrusted_channel_context(&mut unconfigured);
289+
290+
assert_eq!(
291+
configured, unconfigured,
292+
"defaults must not change behaviour for an unconfigured account"
293+
);
294+
}
295+
296+
/// The most permissive ceiling leaves the params carrying no tool policy at
297+
/// all, so nothing on the request side can deny `exec`.
298+
///
299+
/// That is why the `/sh` guard in `dispatch_to_chat` stays tied to
300+
/// `trusted_channel_turn` and not to this ceiling. `run_explicit_shell_command`
301+
/// takes `exec` straight from the request registry, before the
302+
/// `[tools.policy]` layers are consulted, so a guard that widened with the
303+
/// ceiling would hand out an `exec` that no `deny` can take back.
304+
#[test]
305+
fn the_lifted_ceiling_cannot_deny_exec_on_the_request() {
306+
let mut params = serde_json::json!({});
307+
308+
apply_untrusted_channel_context_with(
309+
&mut params,
310+
UntrustedAudience::Trusted,
311+
UntrustedTools::Policy,
312+
);
313+
314+
assert!(params.get("_tool_policy").is_none());
315+
assert!(params.get("_tool_audience").is_none());
316+
assert_eq!(params["_private_context"], false);
317+
}
318+
319+
#[test]
320+
fn each_axis_is_lifted_on_its_own() {
321+
let mut both = serde_json::json!({ "_private_context": true });
322+
apply_untrusted_channel_context_with(
323+
&mut both,
324+
UntrustedAudience::Trusted,
325+
UntrustedTools::Policy,
326+
);
327+
assert!(both.get("_tool_audience").is_none());
328+
assert!(both.get("_tool_policy").is_none());
329+
assert_eq!(
330+
both["_private_context"], false,
331+
"owner-private context is never configurable for a channel turn"
332+
);
333+
334+
let mut audience_only = serde_json::json!({});
335+
apply_untrusted_channel_context_with(
336+
&mut audience_only,
337+
UntrustedAudience::Trusted,
338+
UntrustedTools::default(),
339+
);
340+
assert_eq!(
341+
audience_only["_tool_policy"]["deny"],
342+
serde_json::json!(["*"]),
343+
"lifting the audience alone must still deny every tool by name"
344+
);
345+
346+
let mut tools_only = serde_json::json!({});
347+
apply_untrusted_channel_context_with(
348+
&mut tools_only,
349+
UntrustedAudience::default(),
350+
UntrustedTools::Policy,
351+
);
352+
assert_eq!(
353+
tools_only["_tool_audience"], "public",
354+
"dropping the name policy alone must still hold the audience ceiling"
355+
);
356+
}
357+
278358
#[test]
279359
fn public_audience_tools_require_explicit_registration() {
280360
const REGISTRATION: &str = include_str!("../server/prepare_core/post_state.rs");

crates/whatsapp/src/config.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
use {
22
moltis_channels::{
3-
config_view::ChannelConfigView,
3+
config_view::{ChannelConfigView, UntrustedAudience, UntrustedTools},
44
gating::{DmPolicy, GroupPolicy, MentionMode},
55
},
66
serde::{Deserialize, Serialize},
@@ -74,6 +74,16 @@ pub struct WhatsAppAccountConfig {
7474
/// Group JID allowlist.
7575
pub group_allowlist: Vec<String>,
7676

77+
/// Tool audience ceiling for turns outside an operator direct chat
78+
/// (default: `public`).
79+
#[serde(default)]
80+
pub untrusted_audience: UntrustedAudience,
81+
82+
/// Name policy for turns outside an operator direct chat
83+
/// (default: `deny_all`).
84+
#[serde(default)]
85+
pub untrusted_tools: UntrustedTools,
86+
7787
/// Enable OTP self-approval for non-allowlisted DM users (default: true).
7888
pub otp_self_approval: bool,
7989

@@ -116,6 +126,14 @@ impl ChannelConfigView for WhatsAppAccountConfig {
116126
&self.group_allowlist
117127
}
118128

129+
fn untrusted_audience(&self) -> UntrustedAudience {
130+
self.untrusted_audience
131+
}
132+
133+
fn untrusted_tools(&self) -> UntrustedTools {
134+
self.untrusted_tools
135+
}
136+
119137
fn dm_policy(&self) -> DmPolicy {
120138
self.dm_policy.clone()
121139
}
@@ -189,6 +207,8 @@ impl Default for WhatsAppAccountConfig {
189207
allowlist: Vec::new(),
190208
operators: Vec::new(),
191209
group_allowlist: Vec::new(),
210+
untrusted_audience: UntrustedAudience::default(),
211+
untrusted_tools: UntrustedTools::default(),
192212
otp_self_approval: true,
193213
otp_cooldown_secs: 300,
194214
channel_overrides: HashMap::new(),

docs/src/channels.md

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -378,10 +378,16 @@ separately.
378378
```
379379

380380
Untrusted tool restrictions stack with the per-channel tool policy
381-
(`channels.<type>.<account>.tools.groups.<chat_type>`). Those policies can
382-
further restrict an operator DM, but cannot enable tools for a guest, shared
383-
room, or unknown chat. Grant eligibility for privileged access by adding the
384-
sender to `operators`; the sender must still use a proven direct chat.
381+
(`channels.<type>.<account>.tools.groups.<chat_type>`). By default those
382+
policies can further restrict an operator DM, but cannot enable tools for a
383+
guest, shared room, or unknown chat: the untrusted ceiling denies everything
384+
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.
388+
389+
Grant eligibility for privileged access by adding the sender to `operators`;
390+
the sender must still use a proven direct chat.
385391

386392
### OTP Self-Approval
387393

docs/src/whatsapp.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ Each WhatsApp account is a named entry under `[channels.whatsapp]`:
138138
| `group_allowlist` | array | `[]` | Group JIDs allowed for bot responses |
139139
| `otp_self_approval` | bool | `true` | Allow non-allowlisted users to self-approve via OTP |
140140
| `otp_cooldown_secs` | int | `300` | Cooldown seconds after 3 failed OTP attempts |
141+
| `untrusted_audience` | string | `"public"` | Tool audience ceiling for turns outside an operator direct chat: `"public"` or `"trusted"` |
142+
| `untrusted_tools` | string | `"deny_all"` | Tool name policy for those turns: `"deny_all"`, or `"policy"` to let `[tools.policy]` decide |
141143

142144
### Full Example
143145

0 commit comments

Comments
 (0)