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        assert!(
604            !terminal.always_allow.is_empty(),
605            "terminal should have allow rules"
606        );
607
608        let edit_file = permissions
609            .tools
610            .get("edit_file")
611            .expect("edit_file tool should be configured");
612        assert!(
613            !edit_file.always_deny.is_empty(),
614            "edit_file should have deny rules"
615        );
616
617        let delete_path = permissions
618            .tools
619            .get("delete_path")
620            .expect("delete_path tool should be configured");
621        assert!(
622            !delete_path.always_deny.is_empty(),
623            "delete_path should have deny rules"
624        );
625
626        let fetch = permissions
627            .tools
628            .get("fetch")
629            .expect("fetch tool should be configured");
630        assert!(
631            !fetch.always_allow.is_empty(),
632            "fetch should have allow rules"
633        );
634    }
635
636    #[test]
637    fn test_default_deny_rules_match_dangerous_commands() {
638        let default_json = include_str!("../../../assets/settings/default.json");
639        let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
640        let tool_permissions = value["agent"]["tool_permissions"].clone();
641        let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
642        let permissions = compile_tool_permissions(Some(content));
643
644        let terminal = permissions.tools.get("terminal").unwrap();
645
646        let dangerous_commands = [
647            "rm -rf /",
648            "rm -rf ~",
649            "rm -rf ..",
650            "mkfs.ext4 /dev/sda",
651            "dd if=/dev/zero of=/dev/sda",
652            "cat /etc/passwd",
653            "cat /etc/shadow",
654            "del /f /s /q c:\\",
655            "format c:",
656            "rd /s /q c:\\windows",
657        ];
658
659        for cmd in &dangerous_commands {
660            assert!(
661                terminal.always_deny.iter().any(|r| r.is_match(cmd)),
662                "Command '{}' should be blocked by deny rules",
663                cmd
664            );
665        }
666    }
667
668    #[test]
669    fn test_default_allow_rules_match_safe_commands() {
670        let default_json = include_str!("../../../assets/settings/default.json");
671        let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
672        let tool_permissions = value["agent"]["tool_permissions"].clone();
673        let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
674        let permissions = compile_tool_permissions(Some(content));
675
676        let terminal = permissions.tools.get("terminal").unwrap();
677
678        let safe_commands = [
679            "cargo build",
680            "cargo test",
681            "cargo check",
682            "npm test",
683            "pnpm install",
684            "yarn run build",
685            "ls",
686            "ls -la",
687            "cat file.txt",
688            "git status",
689            "git log",
690            "git diff",
691        ];
692
693        for cmd in &safe_commands {
694            assert!(
695                terminal.always_allow.iter().any(|r| r.is_match(cmd)),
696                "Command '{}' should be allowed by allow rules",
697                cmd
698            );
699        }
700    }
701
702    #[test]
703    fn test_deny_takes_precedence_over_allow_and_confirm() {
704        let json = json!({
705            "tools": {
706                "terminal": {
707                    "default_mode": "allow",
708                    "always_deny": [{ "pattern": "dangerous" }],
709                    "always_confirm": [{ "pattern": "dangerous" }],
710                    "always_allow": [{ "pattern": "dangerous" }]
711                }
712            }
713        });
714
715        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
716        let permissions = compile_tool_permissions(Some(content));
717        let terminal = permissions.tools.get("terminal").unwrap();
718
719        assert!(
720            terminal.always_deny[0].is_match("run dangerous command"),
721            "Deny rule should match"
722        );
723        assert!(
724            terminal.always_allow[0].is_match("run dangerous command"),
725            "Allow rule should also match (but deny takes precedence at evaluation time)"
726        );
727        assert!(
728            terminal.always_confirm[0].is_match("run dangerous command"),
729            "Confirm rule should also match (but deny takes precedence at evaluation time)"
730        );
731    }
732
733    #[test]
734    fn test_confirm_takes_precedence_over_allow() {
735        let json = json!({
736            "tools": {
737                "terminal": {
738                    "default_mode": "allow",
739                    "always_confirm": [{ "pattern": "risky" }],
740                    "always_allow": [{ "pattern": "risky" }]
741                }
742            }
743        });
744
745        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
746        let permissions = compile_tool_permissions(Some(content));
747        let terminal = permissions.tools.get("terminal").unwrap();
748
749        assert!(
750            terminal.always_confirm[0].is_match("do risky thing"),
751            "Confirm rule should match"
752        );
753        assert!(
754            terminal.always_allow[0].is_match("do risky thing"),
755            "Allow rule should also match (but confirm takes precedence at evaluation time)"
756        );
757    }
758
759    #[test]
760    fn test_regex_matches_anywhere_in_string_not_just_anchored() {
761        let json = json!({
762            "tools": {
763                "terminal": {
764                    "always_deny": [
765                        { "pattern": "rm\\s+-rf" },
766                        { "pattern": "/etc/passwd" }
767                    ]
768                }
769            }
770        });
771
772        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
773        let permissions = compile_tool_permissions(Some(content));
774        let terminal = permissions.tools.get("terminal").unwrap();
775
776        assert!(
777            terminal.always_deny[0].is_match("echo hello && rm -rf /"),
778            "Should match rm -rf in the middle of a command chain"
779        );
780        assert!(
781            terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
782            "Should match rm -rf after semicolon"
783        );
784        assert!(
785            terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
786            "Should match /etc/passwd in a pipeline"
787        );
788        assert!(
789            terminal.always_deny[1].is_match("vim /etc/passwd"),
790            "Should match /etc/passwd as argument"
791        );
792    }
793
794    #[test]
795    fn test_fork_bomb_pattern_matches() {
796        let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
797        assert!(
798            fork_bomb_regex.is_match(":(){ :|:& };:"),
799            "Should match the classic fork bomb"
800        );
801        assert!(
802            fork_bomb_regex.is_match(":(){ :|:&};:"),
803            "Should match fork bomb without spaces"
804        );
805    }
806
807    #[test]
808    fn test_default_json_fork_bomb_pattern_matches() {
809        let default_json = include_str!("../../../assets/settings/default.json");
810        let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
811        let tool_permissions = value["agent"]["tool_permissions"].clone();
812        let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
813        let permissions = compile_tool_permissions(Some(content));
814
815        let terminal = permissions.tools.get("terminal").unwrap();
816
817        assert!(
818            terminal
819                .always_deny
820                .iter()
821                .any(|r| r.is_match(":(){ :|:& };:")),
822            "Default deny rules should block the classic fork bomb"
823        );
824    }
825}