agent_settings.rs

  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}