From e5e7e1a409ab7aacfcb5cc6b077ab221c1986fe0 Mon Sep 17 00:00:00 2001 From: Edvard Date: Fri, 20 Feb 2026 12:08:02 -0500 Subject: [PATCH] feat(security): add non_cli_excluded_tools to filter tools on channel messages On non-CLI channels (Telegram, Discord, etc.), tools like shell and file_write cannot receive interactive approval and are auto-denied, causing the LLM to see confusing error responses and fabricate answers. Add a new config option `non_cli_excluded_tools` under `[autonomy]` that removes specified tools from the tool specs sent to the LLM on non-CLI channels. This prevents the model from attempting tool calls that would fail, forcing it to use data already in the system prompt. The change filters tool_specs in run_tool_call_loop when the excluded_tools parameter is non-empty. CLI channels are unaffected. Co-Authored-By: Claude Opus 4.6 --- src/agent/loop_.rs | 15 +++++++++++++-- src/channels/mod.rs | 3 +++ src/config/schema.rs | 12 ++++++++++++ src/tools/delegate.rs | 1 + 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index cde95d31b..20f026bef 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -890,6 +890,7 @@ pub(crate) async fn agent_turn( max_tool_iterations, None, None, + &[], ) .await } @@ -1063,6 +1064,7 @@ pub(crate) async fn run_tool_call_loop( max_tool_iterations: usize, cancellation_token: Option, on_delta: Option>, + excluded_tools: &[String], ) -> Result { let max_iterations = if max_tool_iterations == 0 { DEFAULT_MAX_TOOL_ITERATIONS @@ -1070,8 +1072,11 @@ pub(crate) async fn run_tool_call_loop( max_tool_iterations }; - let tool_specs: Vec = - tools_registry.iter().map(|tool| tool.spec()).collect(); + let tool_specs: Vec = tools_registry + .iter() + .filter(|tool| !excluded_tools.iter().any(|ex| ex == tool.name())) + .map(|tool| tool.spec()) + .collect(); let use_native_tools = provider.supports_native_tools() && !tool_specs.is_empty(); for _iteration in 0..max_iterations { @@ -1621,6 +1626,7 @@ pub async fn run( config.agent.max_tool_iterations, None, None, + &[], ) .await?; final_output = response.clone(); @@ -1740,6 +1746,7 @@ pub async fn run( config.agent.max_tool_iterations, None, None, + &[], ) .await { @@ -2210,6 +2217,7 @@ mod tests { 3, None, None, + &[], ) .await .expect_err("provider without vision support should fail"); @@ -2254,6 +2262,7 @@ mod tests { 3, None, None, + &[], ) .await .expect_err("oversized payload must fail"); @@ -2292,6 +2301,7 @@ mod tests { 3, None, None, + &[], ) .await .expect("valid multimodal payload should pass"); @@ -2412,6 +2422,7 @@ mod tests { 4, None, None, + &[], ) .await .expect("parallel execution should complete"); diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 94e2589c8..81c6e2687 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -210,6 +210,7 @@ struct ChannelRuntimeContext { message_timeout_secs: u64, interrupt_on_new_message: bool, multimodal: crate::config::MultimodalConfig, + non_cli_excluded_tools: Arc>, } #[derive(Clone)] @@ -1477,6 +1478,7 @@ async fn process_channel_message( ctx.max_tool_iterations, Some(cancellation_token.clone()), delta_tx, + if msg.channel == "cli" { &[] } else { ctx.non_cli_excluded_tools.as_ref() }, ), ) => LlmExecutionResult::Completed(result), }; @@ -2924,6 +2926,7 @@ pub async fn start_channels(config: Config) -> Result<()> { message_timeout_secs, interrupt_on_new_message, multimodal: config.multimodal.clone(), + non_cli_excluded_tools: Arc::new(config.autonomy.non_cli_excluded_tools.clone()), }); run_message_dispatch_loop(rx, runtime_ctx, max_in_flight_messages).await; diff --git a/src/config/schema.rs b/src/config/schema.rs index cb7ad82f1..c4812393a 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1734,6 +1734,17 @@ pub struct AutonomyConfig { /// Tools that always require interactive approval, even after "Always". #[serde(default = "default_always_ask")] pub always_ask: Vec, + + /// Tools to exclude from non-CLI channels (e.g. Telegram, Discord). + /// + /// When a tool is listed here, non-CLI channels will not expose it to the + /// LLM — it will not appear in the tool specs and any calls will be + /// rejected. CLI channels are unaffected. + /// + /// Example: `["shell", "file_write"]` prevents the model from attempting + /// shell commands on Telegram, forcing it to use data already in context. + #[serde(default)] + pub non_cli_excluded_tools: Vec, } fn default_auto_approve() -> Vec { @@ -1789,6 +1800,7 @@ impl Default for AutonomyConfig { block_high_risk_commands: true, auto_approve: default_auto_approve(), always_ask: default_always_ask(), + non_cli_excluded_tools: Vec::new(), } } } diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index 94793e1f8..0d1380505 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -409,6 +409,7 @@ impl DelegateTool { agent_config.max_iterations, None, None, + &[], ), ) .await;