1mod agent_profile;
2
3use std::sync::Arc;
4
5use agent_client_protocol::ModelId;
6use collections::{HashSet, IndexMap};
7use gpui::{App, Pixels, px};
8use language_model::LanguageModel;
9use project::DisableAiSettings;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use settings::{
13 DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection,
14 NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
15};
16
17pub use crate::agent_profile::*;
18
19pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt");
20pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
21 include_str!("prompts/summarize_thread_detailed_prompt.txt");
22
23#[derive(Clone, Debug, RegisterSetting)]
24pub struct AgentSettings {
25 pub enabled: bool,
26 pub button: bool,
27 pub dock: DockPosition,
28 pub agents_panel_dock: DockSide,
29 pub default_width: Pixels,
30 pub default_height: Pixels,
31 pub default_model: Option<LanguageModelSelection>,
32 pub inline_assistant_model: Option<LanguageModelSelection>,
33 pub inline_assistant_use_streaming_tools: bool,
34 pub commit_message_model: Option<LanguageModelSelection>,
35 pub thread_summary_model: Option<LanguageModelSelection>,
36 pub inline_alternatives: Vec<LanguageModelSelection>,
37 pub favorite_models: Vec<LanguageModelSelection>,
38 pub default_profile: AgentProfileId,
39 pub default_view: DefaultAgentView,
40 pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
41 pub always_allow_tool_actions: bool,
42 pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
43 pub play_sound_when_agent_done: bool,
44 pub single_file_review: bool,
45 pub model_parameters: Vec<LanguageModelParameters>,
46 pub enable_feedback: bool,
47 pub expand_edit_card: bool,
48 pub expand_terminal_card: bool,
49 pub use_modifier_to_send: bool,
50 pub message_editor_min_lines: usize,
51 pub show_turn_stats: bool,
52 pub tool_permissions: ToolPermissions,
53}
54
55impl AgentSettings {
56 pub fn enabled(&self, cx: &App) -> bool {
57 self.enabled && !DisableAiSettings::get_global(cx).disable_ai
58 }
59
60 pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
61 let settings = Self::get_global(cx);
62 for setting in settings.model_parameters.iter().rev() {
63 if let Some(provider) = &setting.provider
64 && provider.0 != model.provider_id().0
65 {
66 continue;
67 }
68 if let Some(setting_model) = &setting.model
69 && *setting_model != model.id().0
70 {
71 continue;
72 }
73 return setting.temperature;
74 }
75 return None;
76 }
77
78 pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
79 self.inline_assistant_model = Some(LanguageModelSelection {
80 provider: provider.into(),
81 model,
82 });
83 }
84
85 pub fn set_commit_message_model(&mut self, provider: String, model: String) {
86 self.commit_message_model = Some(LanguageModelSelection {
87 provider: provider.into(),
88 model,
89 });
90 }
91
92 pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
93 self.thread_summary_model = Some(LanguageModelSelection {
94 provider: provider.into(),
95 model,
96 });
97 }
98
99 pub fn set_message_editor_max_lines(&self) -> usize {
100 self.message_editor_min_lines * 2
101 }
102
103 pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
104 self.favorite_models
105 .iter()
106 .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
107 .collect()
108 }
109}
110
111#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
112pub struct AgentProfileId(pub Arc<str>);
113
114impl AgentProfileId {
115 pub fn as_str(&self) -> &str {
116 &self.0
117 }
118}
119
120impl std::fmt::Display for AgentProfileId {
121 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122 write!(f, "{}", self.0)
123 }
124}
125
126impl Default for AgentProfileId {
127 fn default() -> Self {
128 Self("write".into())
129 }
130}
131
132#[derive(Clone, Debug, Default)]
133pub struct ToolPermissions {
134 pub tools: collections::HashMap<Arc<str>, ToolRules>,
135}
136
137impl ToolPermissions {
138 /// Returns all invalid regex patterns across all tools.
139 pub fn invalid_patterns(&self) -> Vec<&InvalidRegexPattern> {
140 self.tools
141 .values()
142 .flat_map(|rules| rules.invalid_patterns.iter())
143 .collect()
144 }
145
146 /// Returns true if any tool has invalid regex patterns.
147 pub fn has_invalid_patterns(&self) -> bool {
148 self.tools
149 .values()
150 .any(|rules| !rules.invalid_patterns.is_empty())
151 }
152}
153
154/// Represents a regex pattern that failed to compile.
155#[derive(Clone, Debug)]
156pub struct InvalidRegexPattern {
157 /// The pattern string that failed to compile.
158 pub pattern: String,
159 /// Which rule list this pattern was in (e.g., "always_deny", "always_allow", "always_confirm").
160 pub rule_type: String,
161 /// The error message from the regex compiler.
162 pub error: String,
163}
164
165#[derive(Clone, Debug)]
166pub struct ToolRules {
167 pub default_mode: ToolPermissionMode,
168 pub always_allow: Vec<CompiledRegex>,
169 pub always_deny: Vec<CompiledRegex>,
170 pub always_confirm: Vec<CompiledRegex>,
171 /// Patterns that failed to compile. If non-empty, tool calls should be blocked.
172 pub invalid_patterns: Vec<InvalidRegexPattern>,
173}
174
175impl Default for ToolRules {
176 fn default() -> Self {
177 Self {
178 default_mode: ToolPermissionMode::Confirm,
179 always_allow: Vec::new(),
180 always_deny: Vec::new(),
181 always_confirm: Vec::new(),
182 invalid_patterns: Vec::new(),
183 }
184 }
185}
186
187#[derive(Clone)]
188pub struct CompiledRegex {
189 pub pattern: String,
190 pub case_sensitive: bool,
191 pub regex: regex::Regex,
192}
193
194impl std::fmt::Debug for CompiledRegex {
195 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
196 f.debug_struct("CompiledRegex")
197 .field("pattern", &self.pattern)
198 .field("case_sensitive", &self.case_sensitive)
199 .finish()
200 }
201}
202
203impl CompiledRegex {
204 pub fn new(pattern: &str, case_sensitive: bool) -> Option<Self> {
205 Self::try_new(pattern, case_sensitive).ok()
206 }
207
208 pub fn try_new(pattern: &str, case_sensitive: bool) -> Result<Self, regex::Error> {
209 let regex = regex::RegexBuilder::new(pattern)
210 .case_insensitive(!case_sensitive)
211 .build()?;
212 Ok(Self {
213 pattern: pattern.to_string(),
214 case_sensitive,
215 regex,
216 })
217 }
218
219 pub fn is_match(&self, input: &str) -> bool {
220 self.regex.is_match(input)
221 }
222}
223
224impl Settings for AgentSettings {
225 fn from_settings(content: &settings::SettingsContent) -> Self {
226 let agent = content.agent.clone().unwrap();
227 Self {
228 enabled: agent.enabled.unwrap(),
229 button: agent.button.unwrap(),
230 dock: agent.dock.unwrap(),
231 agents_panel_dock: agent.agents_panel_dock.unwrap(),
232 default_width: px(agent.default_width.unwrap()),
233 default_height: px(agent.default_height.unwrap()),
234 default_model: Some(agent.default_model.unwrap()),
235 inline_assistant_model: agent.inline_assistant_model,
236 inline_assistant_use_streaming_tools: agent
237 .inline_assistant_use_streaming_tools
238 .unwrap_or(true),
239 commit_message_model: agent.commit_message_model,
240 thread_summary_model: agent.thread_summary_model,
241 inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
242 favorite_models: agent.favorite_models,
243 default_profile: AgentProfileId(agent.default_profile.unwrap()),
244 default_view: agent.default_view.unwrap(),
245 profiles: agent
246 .profiles
247 .unwrap()
248 .into_iter()
249 .map(|(key, val)| (AgentProfileId(key), val.into()))
250 .collect(),
251 always_allow_tool_actions: agent.always_allow_tool_actions.unwrap(),
252 notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
253 play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(),
254 single_file_review: agent.single_file_review.unwrap(),
255 model_parameters: agent.model_parameters,
256 enable_feedback: agent.enable_feedback.unwrap(),
257 expand_edit_card: agent.expand_edit_card.unwrap(),
258 expand_terminal_card: agent.expand_terminal_card.unwrap(),
259 use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
260 message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
261 show_turn_stats: agent.show_turn_stats.unwrap(),
262 tool_permissions: compile_tool_permissions(agent.tool_permissions),
263 }
264 }
265}
266
267fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
268 let Some(content) = content else {
269 return ToolPermissions::default();
270 };
271
272 let tools = content
273 .tools
274 .into_iter()
275 .map(|(tool_name, rules_content)| {
276 let mut invalid_patterns = Vec::new();
277
278 let (always_allow, allow_errors) = compile_regex_rules(
279 rules_content.always_allow.map(|v| v.0).unwrap_or_default(),
280 "always_allow",
281 );
282 invalid_patterns.extend(allow_errors);
283
284 let (always_deny, deny_errors) = compile_regex_rules(
285 rules_content.always_deny.map(|v| v.0).unwrap_or_default(),
286 "always_deny",
287 );
288 invalid_patterns.extend(deny_errors);
289
290 let (always_confirm, confirm_errors) = compile_regex_rules(
291 rules_content
292 .always_confirm
293 .map(|v| v.0)
294 .unwrap_or_default(),
295 "always_confirm",
296 );
297 invalid_patterns.extend(confirm_errors);
298
299 // Log invalid patterns for debugging. Users will see an error when they
300 // attempt to use a tool with invalid patterns in their settings.
301 for invalid in &invalid_patterns {
302 log::error!(
303 "Invalid regex pattern in tool_permissions for '{}' tool ({}): '{}' - {}",
304 tool_name,
305 invalid.rule_type,
306 invalid.pattern,
307 invalid.error,
308 );
309 }
310
311 let rules = ToolRules {
312 default_mode: rules_content.default_mode.unwrap_or_default(),
313 always_allow,
314 always_deny,
315 always_confirm,
316 invalid_patterns,
317 };
318 (tool_name, rules)
319 })
320 .collect();
321
322 ToolPermissions { tools }
323}
324
325fn compile_regex_rules(
326 rules: Vec<settings::ToolRegexRule>,
327 rule_type: &str,
328) -> (Vec<CompiledRegex>, Vec<InvalidRegexPattern>) {
329 let mut compiled = Vec::new();
330 let mut errors = Vec::new();
331
332 for rule in rules {
333 let case_sensitive = rule.case_sensitive.unwrap_or(false);
334 match CompiledRegex::try_new(&rule.pattern, case_sensitive) {
335 Ok(regex) => compiled.push(regex),
336 Err(error) => {
337 errors.push(InvalidRegexPattern {
338 pattern: rule.pattern,
339 rule_type: rule_type.to_string(),
340 error: error.to_string(),
341 });
342 }
343 }
344 }
345
346 (compiled, errors)
347}
348
349#[cfg(test)]
350mod tests {
351 use super::*;
352 use serde_json::json;
353 use settings::ToolPermissionsContent;
354
355 #[test]
356 fn test_compiled_regex_case_insensitive() {
357 let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
358 assert!(regex.is_match("rm -rf /"));
359 assert!(regex.is_match("RM -RF /"));
360 assert!(regex.is_match("Rm -Rf /"));
361 }
362
363 #[test]
364 fn test_compiled_regex_case_sensitive() {
365 let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
366 assert!(regex.is_match("DROP TABLE users"));
367 assert!(!regex.is_match("drop table users"));
368 }
369
370 #[test]
371 fn test_invalid_regex_returns_none() {
372 let result = CompiledRegex::new("[invalid(regex", false);
373 assert!(result.is_none());
374 }
375
376 #[test]
377 fn test_tool_permissions_parsing() {
378 let json = json!({
379 "tools": {
380 "terminal": {
381 "default_mode": "allow",
382 "always_deny": [
383 { "pattern": "rm\\s+-rf" }
384 ],
385 "always_allow": [
386 { "pattern": "^git\\s" }
387 ]
388 }
389 }
390 });
391
392 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
393 let permissions = compile_tool_permissions(Some(content));
394
395 let terminal_rules = permissions.tools.get("terminal").unwrap();
396 assert_eq!(terminal_rules.default_mode, ToolPermissionMode::Allow);
397 assert_eq!(terminal_rules.always_deny.len(), 1);
398 assert_eq!(terminal_rules.always_allow.len(), 1);
399 assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
400 assert!(terminal_rules.always_allow[0].is_match("git status"));
401 }
402
403 #[test]
404 fn test_tool_rules_default_mode() {
405 let json = json!({
406 "tools": {
407 "edit_file": {
408 "default_mode": "deny"
409 }
410 }
411 });
412
413 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
414 let permissions = compile_tool_permissions(Some(content));
415
416 let rules = permissions.tools.get("edit_file").unwrap();
417 assert_eq!(rules.default_mode, ToolPermissionMode::Deny);
418 }
419
420 #[test]
421 fn test_tool_permissions_empty() {
422 let permissions = compile_tool_permissions(None);
423 assert!(permissions.tools.is_empty());
424 }
425
426 #[test]
427 fn test_tool_rules_default_returns_confirm() {
428 let default_rules = ToolRules::default();
429 assert_eq!(default_rules.default_mode, ToolPermissionMode::Confirm);
430 assert!(default_rules.always_allow.is_empty());
431 assert!(default_rules.always_deny.is_empty());
432 assert!(default_rules.always_confirm.is_empty());
433 }
434
435 #[test]
436 fn test_tool_permissions_with_multiple_tools() {
437 let json = json!({
438 "tools": {
439 "terminal": {
440 "default_mode": "allow",
441 "always_deny": [{ "pattern": "rm\\s+-rf" }]
442 },
443 "edit_file": {
444 "default_mode": "confirm",
445 "always_deny": [{ "pattern": "\\.env$" }]
446 },
447 "delete_path": {
448 "default_mode": "deny"
449 }
450 }
451 });
452
453 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
454 let permissions = compile_tool_permissions(Some(content));
455
456 assert_eq!(permissions.tools.len(), 3);
457
458 let terminal = permissions.tools.get("terminal").unwrap();
459 assert_eq!(terminal.default_mode, ToolPermissionMode::Allow);
460 assert_eq!(terminal.always_deny.len(), 1);
461
462 let edit_file = permissions.tools.get("edit_file").unwrap();
463 assert_eq!(edit_file.default_mode, ToolPermissionMode::Confirm);
464 assert!(edit_file.always_deny[0].is_match("secrets.env"));
465
466 let delete_path = permissions.tools.get("delete_path").unwrap();
467 assert_eq!(delete_path.default_mode, ToolPermissionMode::Deny);
468 }
469
470 #[test]
471 fn test_tool_permissions_with_all_rule_types() {
472 let json = json!({
473 "tools": {
474 "terminal": {
475 "always_deny": [{ "pattern": "rm\\s+-rf" }],
476 "always_confirm": [{ "pattern": "sudo\\s" }],
477 "always_allow": [{ "pattern": "^git\\s+status" }]
478 }
479 }
480 });
481
482 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
483 let permissions = compile_tool_permissions(Some(content));
484
485 let terminal = permissions.tools.get("terminal").unwrap();
486 assert_eq!(terminal.always_deny.len(), 1);
487 assert_eq!(terminal.always_confirm.len(), 1);
488 assert_eq!(terminal.always_allow.len(), 1);
489
490 assert!(terminal.always_deny[0].is_match("rm -rf /"));
491 assert!(terminal.always_confirm[0].is_match("sudo apt install"));
492 assert!(terminal.always_allow[0].is_match("git status"));
493 }
494
495 #[test]
496 fn test_invalid_regex_is_tracked_and_valid_ones_still_compile() {
497 let json = json!({
498 "tools": {
499 "terminal": {
500 "always_deny": [
501 { "pattern": "[invalid(regex" },
502 { "pattern": "valid_pattern" }
503 ],
504 "always_allow": [
505 { "pattern": "[another_bad" }
506 ]
507 }
508 }
509 });
510
511 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
512 let permissions = compile_tool_permissions(Some(content));
513
514 let terminal = permissions.tools.get("terminal").unwrap();
515
516 // Valid patterns should still be compiled
517 assert_eq!(terminal.always_deny.len(), 1);
518 assert!(terminal.always_deny[0].is_match("valid_pattern"));
519
520 // Invalid patterns should be tracked (order depends on processing order)
521 assert_eq!(terminal.invalid_patterns.len(), 2);
522
523 let deny_invalid = terminal
524 .invalid_patterns
525 .iter()
526 .find(|p| p.rule_type == "always_deny")
527 .expect("should have invalid pattern from always_deny");
528 assert_eq!(deny_invalid.pattern, "[invalid(regex");
529 assert!(!deny_invalid.error.is_empty());
530
531 let allow_invalid = terminal
532 .invalid_patterns
533 .iter()
534 .find(|p| p.rule_type == "always_allow")
535 .expect("should have invalid pattern from always_allow");
536 assert_eq!(allow_invalid.pattern, "[another_bad");
537
538 // ToolPermissions helper methods should work
539 assert!(permissions.has_invalid_patterns());
540 assert_eq!(permissions.invalid_patterns().len(), 2);
541 }
542
543 #[test]
544 fn test_default_json_tool_permissions_parse() {
545 let default_json = include_str!("../../../assets/settings/default.json");
546
547 let value: serde_json::Value = serde_json_lenient::from_str(default_json)
548 .expect("default.json should be valid JSON with comments");
549
550 let agent = value
551 .get("agent")
552 .expect("default.json should have 'agent' key");
553 let tool_permissions = agent
554 .get("tool_permissions")
555 .expect("agent should have 'tool_permissions' key");
556
557 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions.clone())
558 .expect("tool_permissions should parse into ToolPermissionsContent");
559
560 let permissions = compile_tool_permissions(Some(content));
561
562 let terminal = permissions
563 .tools
564 .get("terminal")
565 .expect("terminal tool should be configured");
566 assert!(
567 !terminal.always_deny.is_empty(),
568 "terminal should have deny rules"
569 );
570 assert!(
571 !terminal.always_confirm.is_empty(),
572 "terminal should have confirm rules"
573 );
574 let edit_file = permissions
575 .tools
576 .get("edit_file")
577 .expect("edit_file tool should be configured");
578 assert!(
579 !edit_file.always_deny.is_empty(),
580 "edit_file should have deny rules"
581 );
582
583 let delete_path = permissions
584 .tools
585 .get("delete_path")
586 .expect("delete_path tool should be configured");
587 assert!(
588 !delete_path.always_deny.is_empty(),
589 "delete_path should have deny rules"
590 );
591
592 let fetch = permissions
593 .tools
594 .get("fetch")
595 .expect("fetch tool should be configured");
596 assert_eq!(
597 fetch.default_mode,
598 settings::ToolPermissionMode::Confirm,
599 "fetch should have confirm as default mode"
600 );
601 }
602
603 #[test]
604 fn test_default_deny_rules_match_dangerous_commands() {
605 let default_json = include_str!("../../../assets/settings/default.json");
606 let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
607 let tool_permissions = value["agent"]["tool_permissions"].clone();
608 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
609 let permissions = compile_tool_permissions(Some(content));
610
611 let terminal = permissions.tools.get("terminal").unwrap();
612
613 let dangerous_commands = [
614 "rm -rf /",
615 "rm -rf ~",
616 "rm -rf ..",
617 "mkfs.ext4 /dev/sda",
618 "dd if=/dev/zero of=/dev/sda",
619 "cat /etc/passwd",
620 "cat /etc/shadow",
621 "del /f /s /q c:\\",
622 "format c:",
623 "rd /s /q c:\\windows",
624 ];
625
626 for cmd in &dangerous_commands {
627 assert!(
628 terminal.always_deny.iter().any(|r| r.is_match(cmd)),
629 "Command '{}' should be blocked by deny rules",
630 cmd
631 );
632 }
633 }
634
635 #[test]
636 fn test_deny_takes_precedence_over_allow_and_confirm() {
637 let json = json!({
638 "tools": {
639 "terminal": {
640 "default_mode": "allow",
641 "always_deny": [{ "pattern": "dangerous" }],
642 "always_confirm": [{ "pattern": "dangerous" }],
643 "always_allow": [{ "pattern": "dangerous" }]
644 }
645 }
646 });
647
648 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
649 let permissions = compile_tool_permissions(Some(content));
650 let terminal = permissions.tools.get("terminal").unwrap();
651
652 assert!(
653 terminal.always_deny[0].is_match("run dangerous command"),
654 "Deny rule should match"
655 );
656 assert!(
657 terminal.always_allow[0].is_match("run dangerous command"),
658 "Allow rule should also match (but deny takes precedence at evaluation time)"
659 );
660 assert!(
661 terminal.always_confirm[0].is_match("run dangerous command"),
662 "Confirm rule should also match (but deny takes precedence at evaluation time)"
663 );
664 }
665
666 #[test]
667 fn test_confirm_takes_precedence_over_allow() {
668 let json = json!({
669 "tools": {
670 "terminal": {
671 "default_mode": "allow",
672 "always_confirm": [{ "pattern": "risky" }],
673 "always_allow": [{ "pattern": "risky" }]
674 }
675 }
676 });
677
678 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
679 let permissions = compile_tool_permissions(Some(content));
680 let terminal = permissions.tools.get("terminal").unwrap();
681
682 assert!(
683 terminal.always_confirm[0].is_match("do risky thing"),
684 "Confirm rule should match"
685 );
686 assert!(
687 terminal.always_allow[0].is_match("do risky thing"),
688 "Allow rule should also match (but confirm takes precedence at evaluation time)"
689 );
690 }
691
692 #[test]
693 fn test_regex_matches_anywhere_in_string_not_just_anchored() {
694 let json = json!({
695 "tools": {
696 "terminal": {
697 "always_deny": [
698 { "pattern": "rm\\s+-rf" },
699 { "pattern": "/etc/passwd" }
700 ]
701 }
702 }
703 });
704
705 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
706 let permissions = compile_tool_permissions(Some(content));
707 let terminal = permissions.tools.get("terminal").unwrap();
708
709 assert!(
710 terminal.always_deny[0].is_match("echo hello && rm -rf /"),
711 "Should match rm -rf in the middle of a command chain"
712 );
713 assert!(
714 terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
715 "Should match rm -rf after semicolon"
716 );
717 assert!(
718 terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
719 "Should match /etc/passwd in a pipeline"
720 );
721 assert!(
722 terminal.always_deny[1].is_match("vim /etc/passwd"),
723 "Should match /etc/passwd as argument"
724 );
725 }
726
727 #[test]
728 fn test_fork_bomb_pattern_matches() {
729 let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
730 assert!(
731 fork_bomb_regex.is_match(":(){ :|:& };:"),
732 "Should match the classic fork bomb"
733 );
734 assert!(
735 fork_bomb_regex.is_match(":(){ :|:&};:"),
736 "Should match fork bomb without spaces"
737 );
738 }
739
740 #[test]
741 fn test_default_json_fork_bomb_pattern_matches() {
742 let default_json = include_str!("../../../assets/settings/default.json");
743 let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
744 let tool_permissions = value["agent"]["tool_permissions"].clone();
745 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
746 let permissions = compile_tool_permissions(Some(content));
747
748 let terminal = permissions.tools.get("terminal").unwrap();
749
750 assert!(
751 terminal
752 .always_deny
753 .iter()
754 .any(|r| r.is_match(":(){ :|:& };:")),
755 "Default deny rules should block the classic fork bomb"
756 );
757 }
758
759 #[test]
760 fn test_compiled_regex_stores_case_sensitivity() {
761 let case_sensitive = CompiledRegex::new("test", true).unwrap();
762 let case_insensitive = CompiledRegex::new("test", false).unwrap();
763
764 assert!(case_sensitive.case_sensitive);
765 assert!(!case_insensitive.case_sensitive);
766 }
767
768 #[test]
769 fn test_invalid_regex_is_skipped_not_fail() {
770 let json = json!({
771 "tools": {
772 "terminal": {
773 "always_deny": [
774 { "pattern": "[invalid(regex" },
775 { "pattern": "valid_pattern" }
776 ]
777 }
778 }
779 });
780
781 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
782 let permissions = compile_tool_permissions(Some(content));
783
784 let terminal = permissions.tools.get("terminal").unwrap();
785 assert_eq!(terminal.always_deny.len(), 1);
786 assert!(terminal.always_deny[0].is_match("valid_pattern"));
787 }
788
789 #[test]
790 fn test_unconfigured_tool_not_in_permissions() {
791 let json = json!({
792 "tools": {
793 "terminal": {
794 "default_mode": "allow"
795 }
796 }
797 });
798
799 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
800 let permissions = compile_tool_permissions(Some(content));
801
802 assert!(permissions.tools.contains_key("terminal"));
803 assert!(!permissions.tools.contains_key("edit_file"));
804 assert!(!permissions.tools.contains_key("fetch"));
805 }
806
807 #[test]
808 fn test_always_allow_pattern_only_matches_specified_commands() {
809 // Reproduces user-reported bug: when always_allow has pattern "^echo\s",
810 // only "echo hello" should be allowed, not "git status".
811 //
812 // User config:
813 // always_allow_tool_actions: false
814 // tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
815 let json = json!({
816 "tools": {
817 "terminal": {
818 "always_allow": [
819 { "pattern": "^echo\\s" }
820 ]
821 }
822 }
823 });
824
825 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
826 let permissions = compile_tool_permissions(Some(content));
827
828 let terminal = permissions.tools.get("terminal").unwrap();
829
830 // Verify the pattern was compiled
831 assert_eq!(
832 terminal.always_allow.len(),
833 1,
834 "Should have one always_allow pattern"
835 );
836
837 // Verify the pattern matches "echo hello"
838 assert!(
839 terminal.always_allow[0].is_match("echo hello"),
840 "Pattern ^echo\\s should match 'echo hello'"
841 );
842
843 // Verify the pattern does NOT match "git status"
844 assert!(
845 !terminal.always_allow[0].is_match("git status"),
846 "Pattern ^echo\\s should NOT match 'git status'"
847 );
848
849 // Verify the pattern does NOT match "echoHello" (no space)
850 assert!(
851 !terminal.always_allow[0].is_match("echoHello"),
852 "Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
853 );
854
855 // Verify default_mode is Confirm (the default)
856 assert_eq!(
857 terminal.default_mode,
858 settings::ToolPermissionMode::Confirm,
859 "default_mode should be Confirm when not specified"
860 );
861 }
862}