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