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