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