Skip to content

Commit fdeb08d

Browse files
authored
feat: Implement wildcard pattern matching for agent allowedTools (#2612)
- Add globset-based pattern matching to support wildcards (* and ?) in allowedTools - Create util/pattern_matching.rs module with matches_any_pattern function - Update all native tools (fs_read, fs_write, execute_bash, use_aws, knowledge) to use pattern matching - Update MCP custom tools to support wildcard patterns while preserving exact server-level matching - Standardize imports across tool files for consistency - Maintain backward compatibility with existing exact-match behavior Enables agent configs like: - "fs_*" matches fs_read, fs_write - "@mcp-server/tool_*" matches tool_read, tool_write - "execute_*" matches execute_bash, execute_cmd
1 parent 71c0081 commit fdeb08d

File tree

11 files changed

+276
-29
lines changed

11 files changed

+276
-29
lines changed

crates/chat-cli/src/cli/agent/mod.rs

Lines changed: 127 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -693,18 +693,31 @@ impl Agents {
693693

694694
/// Returns a label to describe the permission status for a given tool.
695695
pub fn display_label(&self, tool_name: &str, origin: &ToolOrigin) -> String {
696+
use crate::util::pattern_matching::matches_any_pattern;
697+
696698
let tool_trusted = self.get_active().is_some_and(|a| {
699+
if matches!(origin, &ToolOrigin::Native) {
700+
return matches_any_pattern(&a.allowed_tools, tool_name);
701+
}
702+
697703
a.allowed_tools.iter().any(|name| {
698-
// Here the tool names can take the following forms:
699-
// - @{server_name}{delimiter}{tool_name}
700-
// - native_tool_name
701-
name == tool_name && matches!(origin, &ToolOrigin::Native)
702-
|| name.strip_prefix("@").is_some_and(|remainder| {
703-
remainder
704-
.split_once(MCP_SERVER_TOOL_DELIMITER)
705-
.is_some_and(|(_left, right)| right == tool_name)
706-
|| remainder == <ToolOrigin as Borrow<str>>::borrow(origin)
707-
})
704+
name.strip_prefix("@").is_some_and(|remainder| {
705+
remainder
706+
.split_once(MCP_SERVER_TOOL_DELIMITER)
707+
.is_some_and(|(_left, right)| right == tool_name)
708+
|| remainder == <ToolOrigin as Borrow<str>>::borrow(origin)
709+
}) || {
710+
if let Some(server_name) = name.strip_prefix("@").and_then(|s| s.split('/').next()) {
711+
if server_name == <ToolOrigin as Borrow<str>>::borrow(origin) {
712+
let tool_pattern = format!("@{}/{}", server_name, tool_name);
713+
matches_any_pattern(&a.allowed_tools, &tool_pattern)
714+
} else {
715+
false
716+
}
717+
} else {
718+
false
719+
}
720+
}
708721
})
709722
});
710723

@@ -942,4 +955,108 @@ mod tests {
942955
assert!(validate_agent_name("invalid!").is_err());
943956
assert!(validate_agent_name("invalid space").is_err());
944957
}
958+
959+
#[test]
960+
fn test_display_label_no_active_agent() {
961+
let agents = Agents::default();
962+
963+
let label = agents.display_label("fs_read", &ToolOrigin::Native);
964+
// With no active agent, it should fall back to default permissions
965+
// fs_read has a default of "trusted"
966+
assert!(label.contains("trusted"), "fs_read should show default trusted permission, instead found: {}", label);
967+
}
968+
969+
#[test]
970+
fn test_display_label_trust_all_tools() {
971+
let mut agents = Agents::default();
972+
agents.trust_all_tools = true;
973+
974+
// Should be trusted even if not in allowed_tools
975+
let label = agents.display_label("random_tool", &ToolOrigin::Native);
976+
assert!(label.contains("trusted"), "trust_all_tools should make everything trusted, instead found: {}", label);
977+
}
978+
979+
#[test]
980+
fn test_display_label_default_permissions() {
981+
let agents = Agents::default();
982+
983+
// Test default permissions for known tools
984+
let fs_read_label = agents.display_label("fs_read", &ToolOrigin::Native);
985+
assert!(fs_read_label.contains("trusted"), "fs_read should be trusted by default, instead found: {}", fs_read_label);
986+
987+
let fs_write_label = agents.display_label("fs_write", &ToolOrigin::Native);
988+
assert!(fs_write_label.contains("not trusted"), "fs_write should not be trusted by default, instead found: {}", fs_write_label);
989+
990+
let execute_bash_label = agents.display_label("execute_bash", &ToolOrigin::Native);
991+
assert!(execute_bash_label.contains("read-only"), "execute_bash should show read-only by default, instead found: {}", execute_bash_label);
992+
}
993+
994+
#[test]
995+
fn test_display_label_comprehensive_patterns() {
996+
let mut agents = Agents::default();
997+
998+
// Create agent with all types of patterns
999+
let mut allowed_tools = HashSet::new();
1000+
// Native exact match
1001+
allowed_tools.insert("fs_read".to_string());
1002+
// Native wildcard
1003+
allowed_tools.insert("execute_*".to_string());
1004+
// MCP server exact (allows all tools from that server)
1005+
allowed_tools.insert("@server1".to_string());
1006+
// MCP tool exact
1007+
allowed_tools.insert("@server2/specific_tool".to_string());
1008+
// MCP tool wildcard
1009+
allowed_tools.insert("@server3/tool_*".to_string());
1010+
1011+
let agent = Agent {
1012+
schema: "test".to_string(),
1013+
name: "test-agent".to_string(),
1014+
description: None,
1015+
prompt: None,
1016+
mcp_servers: Default::default(),
1017+
tools: Vec::new(),
1018+
tool_aliases: Default::default(),
1019+
allowed_tools,
1020+
tools_settings: Default::default(),
1021+
resources: Vec::new(),
1022+
hooks: Default::default(),
1023+
use_legacy_mcp_json: false,
1024+
path: None,
1025+
};
1026+
1027+
agents.agents.insert("test-agent".to_string(), agent);
1028+
agents.active_idx = "test-agent".to_string();
1029+
1030+
// Test 1: Native exact match
1031+
let label = agents.display_label("fs_read", &ToolOrigin::Native);
1032+
assert!(label.contains("trusted"), "fs_read should be trusted (exact match), instead found: {}", label);
1033+
1034+
// Test 2: Native wildcard match
1035+
let label = agents.display_label("execute_bash", &ToolOrigin::Native);
1036+
assert!(label.contains("trusted"), "execute_bash should match execute_* pattern, instead found: {}", label);
1037+
1038+
// Test 3: Native no match
1039+
let label = agents.display_label("fs_write", &ToolOrigin::Native);
1040+
assert!(!label.contains("trusted") || label.contains("not trusted"), "fs_write should not be trusted, instead found: {}", label);
1041+
1042+
// Test 4: MCP server exact match (allows any tool from server1)
1043+
let label = agents.display_label("any_tool", &ToolOrigin::McpServer("server1".to_string()));
1044+
assert!(label.contains("trusted"), "Server-level permission should allow any tool, instead found: {}", label);
1045+
1046+
// Test 5: MCP tool exact match
1047+
let label = agents.display_label("specific_tool", &ToolOrigin::McpServer("server2".to_string()));
1048+
assert!(label.contains("trusted"), "Exact MCP tool should be trusted, instead found: {}", label);
1049+
1050+
// Test 6: MCP tool wildcard match
1051+
let label = agents.display_label("tool_read", &ToolOrigin::McpServer("server3".to_string()));
1052+
assert!(label.contains("trusted"), "tool_read should match @server3/tool_* pattern, instead found: {}", label);
1053+
1054+
// Test 7: MCP tool no match
1055+
let label = agents.display_label("other_tool", &ToolOrigin::McpServer("server2".to_string()));
1056+
assert!(!label.contains("trusted") || label.contains("not trusted"), "Non-matching MCP tool should not be trusted, instead found: {}", label);
1057+
1058+
// Test 8: MCP server no match
1059+
let label = agents.display_label("some_tool", &ToolOrigin::McpServer("unknown_server".to_string()));
1060+
assert!(!label.contains("trusted") || label.contains("not trusted"), "Unknown server should not be trusted, instead found: {}", label);
1061+
}
9451062
}

crates/chat-cli/src/cli/chat/conversation.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ impl ConversationState {
135135
current_model_id: Option<String>,
136136
os: &Os,
137137
) -> Self {
138+
138139
let model = if let Some(model_id) = current_model_id {
139140
match get_model_info(&model_id, os).await {
140141
Ok(info) => Some(info),
@@ -1278,4 +1279,5 @@ mod tests {
12781279
conversation.set_next_user_message(i.to_string()).await;
12791280
}
12801281
}
1282+
12811283
}

crates/chat-cli/src/cli/chat/tools/custom_tool.rs

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ use crate::mcp_client::{
3535
ToolCallResult,
3636
};
3737
use crate::os::Os;
38+
use crate::util::pattern_matching::matches_any_pattern;
39+
use crate::util::MCP_SERVER_TOOL_DELIMITER;
3840

3941
// TODO: support http transport type
4042
#[derive(Clone, Serialize, Deserialize, Debug, Eq, PartialEq, JsonSchema)]
@@ -274,23 +276,24 @@ impl CustomTool {
274276
}
275277

276278
pub fn eval_perm(&self, agent: &Agent) -> PermissionEvalResult {
277-
use crate::util::MCP_SERVER_TOOL_DELIMITER;
278279
let Self {
279280
name: tool_name,
280281
client,
281282
..
282283
} = self;
283284
let server_name = client.get_server_name();
284285

285-
if agent.allowed_tools.contains(&format!("@{server_name}"))
286-
|| agent
287-
.allowed_tools
288-
.contains(&format!("@{server_name}{MCP_SERVER_TOOL_DELIMITER}{tool_name}"))
289-
{
290-
PermissionEvalResult::Allow
291-
} else {
292-
PermissionEvalResult::Ask
286+
let server_pattern = format!("@{server_name}");
287+
if agent.allowed_tools.contains(&server_pattern) {
288+
return PermissionEvalResult::Allow;
289+
}
290+
291+
let tool_pattern = format!("@{server_name}{MCP_SERVER_TOOL_DELIMITER}{tool_name}");
292+
if matches_any_pattern(&agent.allowed_tools, &tool_pattern) {
293+
return PermissionEvalResult::Allow;
293294
}
295+
296+
PermissionEvalResult::Ask
294297
}
295298
}
296299

crates/chat-cli/src/cli/chat/tools/execute/mod.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ use crate::cli::chat::tools::{
2323
};
2424
use crate::cli::chat::util::truncate_safe;
2525
use crate::os::Os;
26+
use crate::util::pattern_matching::matches_any_pattern;
2627

2728
// Platform-specific modules
2829
#[cfg(windows)]
@@ -204,7 +205,7 @@ impl ExecuteCommand {
204205

205206
let Self { command, .. } = self;
206207
let tool_name = if cfg!(windows) { "execute_cmd" } else { "execute_bash" };
207-
let is_in_allowlist = agent.allowed_tools.contains(tool_name);
208+
let is_in_allowlist = matches_any_pattern(&agent.allowed_tools, tool_name);
208209
match agent.tools_settings.get(tool_name) {
209210
Some(settings) if is_in_allowlist => {
210211
let Settings {

crates/chat-cli/src/cli/chat/tools/fs_read.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ use crate::cli::chat::{
4848
sanitize_unicode_tags,
4949
};
5050
use crate::os::Os;
51+
use crate::util::pattern_matching::matches_any_pattern;
5152

5253
#[derive(Debug, Clone, Deserialize)]
5354
pub struct FsRead {
@@ -118,7 +119,7 @@ impl FsRead {
118119
true
119120
}
120121

121-
let is_in_allowlist = agent.allowed_tools.contains("fs_read");
122+
let is_in_allowlist = matches_any_pattern(&agent.allowed_tools, "fs_read");
122123
match agent.tools_settings.get("fs_read") {
123124
Some(settings) if is_in_allowlist => {
124125
let Settings {

crates/chat-cli/src/cli/chat/tools/fs_write.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ use crate::cli::agent::{
4747
};
4848
use crate::cli::chat::line_tracker::FileLineTracker;
4949
use crate::os::Os;
50+
use crate::util::pattern_matching::matches_any_pattern;
5051

5152
static SYNTAX_SET: LazyLock<SyntaxSet> = LazyLock::new(SyntaxSet::load_defaults_newlines);
5253
static THEME_SET: LazyLock<ThemeSet> = LazyLock::new(ThemeSet::load_defaults);
@@ -425,7 +426,7 @@ impl FsWrite {
425426
denied_paths: Vec<String>,
426427
}
427428

428-
let is_in_allowlist = agent.allowed_tools.contains("fs_write");
429+
let is_in_allowlist = matches_any_pattern(&agent.allowed_tools, "fs_write");
429430
match agent.tools_settings.get("fs_write") {
430431
Some(settings) if is_in_allowlist => {
431432
let Settings {

crates/chat-cli/src/cli/chat/tools/knowledge.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::cli::agent::{
1919
};
2020
use crate::database::settings::Setting;
2121
use crate::os::Os;
22+
use crate::util::pattern_matching::matches_any_pattern;
2223
use crate::util::knowledge_store::KnowledgeStore;
2324

2425
/// The Knowledge tool allows storing and retrieving information across chat sessions.
@@ -490,7 +491,7 @@ impl Knowledge {
490491

491492
pub fn eval_perm(&self, agent: &Agent) -> PermissionEvalResult {
492493
_ = self;
493-
if agent.allowed_tools.contains("knowledge") {
494+
if matches_any_pattern(&agent.allowed_tools, "knowledge") {
494495
PermissionEvalResult::Allow
495496
} else {
496497
PermissionEvalResult::Ask

crates/chat-cli/src/cli/chat/tools/use_aws.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ use crate::cli::agent::{
2929
PermissionEvalResult,
3030
};
3131
use crate::os::Os;
32+
use crate::util::pattern_matching::matches_any_pattern;
3233

3334
const READONLY_OPS: [&str; 6] = ["get", "describe", "list", "ls", "search", "batch_get"];
3435

@@ -184,7 +185,7 @@ impl UseAws {
184185
}
185186

186187
let Self { service_name, .. } = self;
187-
let is_in_allowlist = agent.allowed_tools.contains("use_aws");
188+
let is_in_allowlist = matches_any_pattern(&agent.allowed_tools, "use_aws");
188189
match agent.tools_settings.get("use_aws") {
189190
Some(settings) if is_in_allowlist => {
190191
let settings = match serde_json::from_value::<Settings>(settings.clone()) {

crates/chat-cli/src/util/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ pub mod consts;
22
pub mod directories;
33
pub mod knowledge_store;
44
pub mod open;
5+
pub mod pattern_matching;
56
pub mod process;
67
pub mod spinner;
78
pub mod system_info;
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
use std::collections::HashSet;
2+
use globset::Glob;
3+
4+
/// Check if a string matches any pattern in a set of patterns
5+
pub fn matches_any_pattern(patterns: &HashSet<String>, text: &str) -> bool {
6+
patterns.iter().any(|pattern| {
7+
// Exact match first
8+
if pattern == text {
9+
return true;
10+
}
11+
12+
// Glob pattern match if contains wildcards
13+
if pattern.contains('*') || pattern.contains('?') {
14+
if let Ok(glob) = Glob::new(pattern) {
15+
return glob.compile_matcher().is_match(text);
16+
}
17+
}
18+
19+
false
20+
})
21+
}
22+
23+
#[cfg(test)]
24+
mod tests {
25+
use super::*;
26+
use std::collections::HashSet;
27+
28+
#[test]
29+
fn test_exact_match() {
30+
let mut patterns = HashSet::new();
31+
patterns.insert("fs_read".to_string());
32+
33+
assert!(matches_any_pattern(&patterns, "fs_read"));
34+
assert!(!matches_any_pattern(&patterns, "fs_write"));
35+
}
36+
37+
#[test]
38+
fn test_wildcard_patterns() {
39+
let mut patterns = HashSet::new();
40+
patterns.insert("fs_*".to_string());
41+
42+
assert!(matches_any_pattern(&patterns, "fs_read"));
43+
assert!(matches_any_pattern(&patterns, "fs_write"));
44+
assert!(!matches_any_pattern(&patterns, "execute_bash"));
45+
}
46+
47+
#[test]
48+
fn test_mcp_patterns() {
49+
let mut patterns = HashSet::new();
50+
patterns.insert("@mcp-server/*".to_string());
51+
52+
assert!(matches_any_pattern(&patterns, "@mcp-server/tool1"));
53+
assert!(matches_any_pattern(&patterns, "@mcp-server/tool2"));
54+
assert!(!matches_any_pattern(&patterns, "@other-server/tool"));
55+
}
56+
57+
#[test]
58+
fn test_question_mark_wildcard() {
59+
let mut patterns = HashSet::new();
60+
patterns.insert("fs_?ead".to_string());
61+
62+
assert!(matches_any_pattern(&patterns, "fs_read"));
63+
assert!(!matches_any_pattern(&patterns, "fs_write"));
64+
}
65+
}

0 commit comments

Comments
 (0)