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_deny_takes_precedence_over_allow_and_confirm() {
547        let json = json!({
548            "tools": {
549                "terminal": {
550                    "default_mode": "allow",
551                    "always_deny": [{ "pattern": "dangerous" }],
552                    "always_confirm": [{ "pattern": "dangerous" }],
553                    "always_allow": [{ "pattern": "dangerous" }]
554                }
555            }
556        });
557
558        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
559        let permissions = compile_tool_permissions(Some(content));
560        let terminal = permissions.tools.get("terminal").unwrap();
561
562        assert!(
563            terminal.always_deny[0].is_match("run dangerous command"),
564            "Deny rule should match"
565        );
566        assert!(
567            terminal.always_allow[0].is_match("run dangerous command"),
568            "Allow rule should also match (but deny takes precedence at evaluation time)"
569        );
570        assert!(
571            terminal.always_confirm[0].is_match("run dangerous command"),
572            "Confirm rule should also match (but deny takes precedence at evaluation time)"
573        );
574    }
575
576    #[test]
577    fn test_confirm_takes_precedence_over_allow() {
578        let json = json!({
579            "tools": {
580                "terminal": {
581                    "default_mode": "allow",
582                    "always_confirm": [{ "pattern": "risky" }],
583                    "always_allow": [{ "pattern": "risky" }]
584                }
585            }
586        });
587
588        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
589        let permissions = compile_tool_permissions(Some(content));
590        let terminal = permissions.tools.get("terminal").unwrap();
591
592        assert!(
593            terminal.always_confirm[0].is_match("do risky thing"),
594            "Confirm rule should match"
595        );
596        assert!(
597            terminal.always_allow[0].is_match("do risky thing"),
598            "Allow rule should also match (but confirm takes precedence at evaluation time)"
599        );
600    }
601
602    #[test]
603    fn test_regex_matches_anywhere_in_string_not_just_anchored() {
604        let json = json!({
605            "tools": {
606                "terminal": {
607                    "always_deny": [
608                        { "pattern": "rm\\s+-rf" },
609                        { "pattern": "/etc/passwd" }
610                    ]
611                }
612            }
613        });
614
615        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
616        let permissions = compile_tool_permissions(Some(content));
617        let terminal = permissions.tools.get("terminal").unwrap();
618
619        assert!(
620            terminal.always_deny[0].is_match("echo hello && rm -rf /"),
621            "Should match rm -rf in the middle of a command chain"
622        );
623        assert!(
624            terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
625            "Should match rm -rf after semicolon"
626        );
627        assert!(
628            terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
629            "Should match /etc/passwd in a pipeline"
630        );
631        assert!(
632            terminal.always_deny[1].is_match("vim /etc/passwd"),
633            "Should match /etc/passwd as argument"
634        );
635    }
636
637    #[test]
638    fn test_fork_bomb_pattern_matches() {
639        let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
640        assert!(
641            fork_bomb_regex.is_match(":(){ :|:& };:"),
642            "Should match the classic fork bomb"
643        );
644        assert!(
645            fork_bomb_regex.is_match(":(){ :|:&};:"),
646            "Should match fork bomb without spaces"
647        );
648    }
649
650    #[test]
651    fn test_compiled_regex_stores_case_sensitivity() {
652        let case_sensitive = CompiledRegex::new("test", true).unwrap();
653        let case_insensitive = CompiledRegex::new("test", false).unwrap();
654
655        assert!(case_sensitive.case_sensitive);
656        assert!(!case_insensitive.case_sensitive);
657    }
658
659    #[test]
660    fn test_invalid_regex_is_skipped_not_fail() {
661        let json = json!({
662            "tools": {
663                "terminal": {
664                    "always_deny": [
665                        { "pattern": "[invalid(regex" },
666                        { "pattern": "valid_pattern" }
667                    ]
668                }
669            }
670        });
671
672        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
673        let permissions = compile_tool_permissions(Some(content));
674
675        let terminal = permissions.tools.get("terminal").unwrap();
676        assert_eq!(terminal.always_deny.len(), 1);
677        assert!(terminal.always_deny[0].is_match("valid_pattern"));
678    }
679
680    #[test]
681    fn test_unconfigured_tool_not_in_permissions() {
682        let json = json!({
683            "tools": {
684                "terminal": {
685                    "default_mode": "allow"
686                }
687            }
688        });
689
690        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
691        let permissions = compile_tool_permissions(Some(content));
692
693        assert!(permissions.tools.contains_key("terminal"));
694        assert!(!permissions.tools.contains_key("edit_file"));
695        assert!(!permissions.tools.contains_key("fetch"));
696    }
697
698    #[test]
699    fn test_always_allow_pattern_only_matches_specified_commands() {
700        // Reproduces user-reported bug: when always_allow has pattern "^echo\s",
701        // only "echo hello" should be allowed, not "git status".
702        //
703        // User config:
704        //   always_allow_tool_actions: false
705        //   tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
706        let json = json!({
707            "tools": {
708                "terminal": {
709                    "always_allow": [
710                        { "pattern": "^echo\\s" }
711                    ]
712                }
713            }
714        });
715
716        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
717        let permissions = compile_tool_permissions(Some(content));
718
719        let terminal = permissions.tools.get("terminal").unwrap();
720
721        // Verify the pattern was compiled
722        assert_eq!(
723            terminal.always_allow.len(),
724            1,
725            "Should have one always_allow pattern"
726        );
727
728        // Verify the pattern matches "echo hello"
729        assert!(
730            terminal.always_allow[0].is_match("echo hello"),
731            "Pattern ^echo\\s should match 'echo hello'"
732        );
733
734        // Verify the pattern does NOT match "git status"
735        assert!(
736            !terminal.always_allow[0].is_match("git status"),
737            "Pattern ^echo\\s should NOT match 'git status'"
738        );
739
740        // Verify the pattern does NOT match "echoHello" (no space)
741        assert!(
742            !terminal.always_allow[0].is_match("echoHello"),
743            "Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
744        );
745
746        // Verify default_mode is Confirm (the default)
747        assert_eq!(
748            terminal.default_mode,
749            settings::ToolPermissionMode::Confirm,
750            "default_mode should be Confirm when not specified"
751        );
752    }
753}