agent_settings.rs

   1mod agent_profile;
   2
   3use std::path::{Component, Path};
   4use std::sync::{Arc, LazyLock};
   5
   6use agent_client_protocol::ModelId;
   7use collections::{HashSet, IndexMap};
   8use gpui::{App, Pixels, px};
   9use language_model::LanguageModel;
  10use project::DisableAiSettings;
  11use schemars::JsonSchema;
  12use serde::{Deserialize, Serialize};
  13use settings::{
  14    DefaultAgentView, DockPosition, LanguageModelParameters, LanguageModelSelection,
  15    NewThreadLocation, NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
  16};
  17
  18pub use crate::agent_profile::*;
  19
  20pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt");
  21pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
  22    include_str!("prompts/summarize_thread_detailed_prompt.txt");
  23
  24#[derive(Clone, Debug, RegisterSetting)]
  25pub struct AgentSettings {
  26    pub enabled: bool,
  27    pub button: bool,
  28    pub dock: DockPosition,
  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
  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    pub new_thread_location: NewThreadLocation,
  55}
  56
  57impl AgentSettings {
  58    pub fn enabled(&self, cx: &App) -> bool {
  59        self.enabled && !DisableAiSettings::get_global(cx).disable_ai
  60    }
  61
  62    pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
  63        let settings = Self::get_global(cx);
  64        for setting in settings.model_parameters.iter().rev() {
  65            if let Some(provider) = &setting.provider
  66                && provider.0 != model.provider_id().0
  67            {
  68                continue;
  69            }
  70            if let Some(setting_model) = &setting.model
  71                && *setting_model != model.id().0
  72            {
  73                continue;
  74            }
  75            return setting.temperature;
  76        }
  77        return None;
  78    }
  79
  80    pub fn set_message_editor_max_lines(&self) -> usize {
  81        self.message_editor_min_lines * 2
  82    }
  83
  84    pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
  85        self.favorite_models
  86            .iter()
  87            .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
  88            .collect()
  89    }
  90}
  91
  92#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
  93pub struct AgentProfileId(pub Arc<str>);
  94
  95impl AgentProfileId {
  96    pub fn as_str(&self) -> &str {
  97        &self.0
  98    }
  99}
 100
 101impl std::fmt::Display for AgentProfileId {
 102    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 103        write!(f, "{}", self.0)
 104    }
 105}
 106
 107impl Default for AgentProfileId {
 108    fn default() -> Self {
 109        Self("write".into())
 110    }
 111}
 112
 113#[derive(Clone, Debug, Default)]
 114pub struct ToolPermissions {
 115    /// Global default permission when no tool-specific rules or patterns match.
 116    pub default: ToolPermissionMode,
 117    pub tools: collections::HashMap<Arc<str>, ToolRules>,
 118}
 119
 120impl ToolPermissions {
 121    /// Returns all invalid regex patterns across all tools.
 122    pub fn invalid_patterns(&self) -> Vec<&InvalidRegexPattern> {
 123        self.tools
 124            .values()
 125            .flat_map(|rules| rules.invalid_patterns.iter())
 126            .collect()
 127    }
 128
 129    /// Returns true if any tool has invalid regex patterns.
 130    pub fn has_invalid_patterns(&self) -> bool {
 131        self.tools
 132            .values()
 133            .any(|rules| !rules.invalid_patterns.is_empty())
 134    }
 135}
 136
 137/// Represents a regex pattern that failed to compile.
 138#[derive(Clone, Debug)]
 139pub struct InvalidRegexPattern {
 140    /// The pattern string that failed to compile.
 141    pub pattern: String,
 142    /// Which rule list this pattern was in (e.g., "always_deny", "always_allow", "always_confirm").
 143    pub rule_type: String,
 144    /// The error message from the regex compiler.
 145    pub error: String,
 146}
 147
 148#[derive(Clone, Debug, Default)]
 149pub struct ToolRules {
 150    pub default: Option<ToolPermissionMode>,
 151    pub always_allow: Vec<CompiledRegex>,
 152    pub always_deny: Vec<CompiledRegex>,
 153    pub always_confirm: Vec<CompiledRegex>,
 154    /// Patterns that failed to compile. If non-empty, tool calls should be blocked.
 155    pub invalid_patterns: Vec<InvalidRegexPattern>,
 156}
 157
 158#[derive(Clone)]
 159pub struct CompiledRegex {
 160    pub pattern: String,
 161    pub case_sensitive: bool,
 162    pub regex: regex::Regex,
 163}
 164
 165impl std::fmt::Debug for CompiledRegex {
 166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
 167        f.debug_struct("CompiledRegex")
 168            .field("pattern", &self.pattern)
 169            .field("case_sensitive", &self.case_sensitive)
 170            .finish()
 171    }
 172}
 173
 174impl CompiledRegex {
 175    pub fn new(pattern: &str, case_sensitive: bool) -> Option<Self> {
 176        Self::try_new(pattern, case_sensitive).ok()
 177    }
 178
 179    pub fn try_new(pattern: &str, case_sensitive: bool) -> Result<Self, regex::Error> {
 180        let regex = regex::RegexBuilder::new(pattern)
 181            .case_insensitive(!case_sensitive)
 182            .build()?;
 183        Ok(Self {
 184            pattern: pattern.to_string(),
 185            case_sensitive,
 186            regex,
 187        })
 188    }
 189
 190    pub fn is_match(&self, input: &str) -> bool {
 191        self.regex.is_match(input)
 192    }
 193}
 194
 195pub const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \
 196     harmful to be allowed, and cannot be overridden by settings.";
 197
 198/// Security rules that are always enforced and cannot be overridden by any setting.
 199/// These protect against catastrophic operations like wiping filesystems.
 200pub struct HardcodedSecurityRules {
 201    pub terminal_deny: Vec<CompiledRegex>,
 202}
 203
 204pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {
 205    const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";
 206    const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";
 207
 208    HardcodedSecurityRules {
 209        terminal_deny: vec![
 210            // Recursive deletion of root - "rm -rf /", "rm -rf /*"
 211            CompiledRegex::new(
 212                &format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),
 213                false,
 214            )
 215            .expect("hardcoded regex should compile"),
 216            // Recursive deletion of home via tilde - "rm -rf ~", "rm -rf ~/"
 217            CompiledRegex::new(
 218                &format!(r"\brm\s+{FLAGS}(--\s+)?~/?\*?{TRAILING_FLAGS}$"),
 219                false,
 220            )
 221            .expect("hardcoded regex should compile"),
 222            // Recursive deletion of home via env var - "rm -rf $HOME", "rm -rf ${HOME}"
 223            CompiledRegex::new(
 224                &format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),
 225                false,
 226            )
 227            .expect("hardcoded regex should compile"),
 228            // Recursive deletion of current directory - "rm -rf .", "rm -rf ./"
 229            CompiledRegex::new(
 230                &format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),
 231                false,
 232            )
 233            .expect("hardcoded regex should compile"),
 234            // Recursive deletion of parent directory - "rm -rf ..", "rm -rf ../"
 235            CompiledRegex::new(
 236                &format!(r"\brm\s+{FLAGS}(--\s+)?\.\./?\*?{TRAILING_FLAGS}$"),
 237                false,
 238            )
 239            .expect("hardcoded regex should compile"),
 240        ],
 241    }
 242});
 243
 244/// Checks if input matches any hardcoded security rules that cannot be bypassed.
 245/// Returns the denial reason string if blocked, None otherwise.
 246///
 247/// `terminal_tool_name` should be the tool name used for the terminal tool
 248/// (e.g. `"terminal"`). `extracted_commands` can optionally provide parsed
 249/// sub-commands for chained command checking; callers with access to a shell
 250/// parser should extract sub-commands and pass them here.
 251pub fn check_hardcoded_security_rules(
 252    tool_name: &str,
 253    terminal_tool_name: &str,
 254    input: &str,
 255    extracted_commands: Option<&[String]>,
 256) -> Option<String> {
 257    if tool_name != terminal_tool_name {
 258        return None;
 259    }
 260
 261    let rules = &*HARDCODED_SECURITY_RULES;
 262    let terminal_patterns = &rules.terminal_deny;
 263
 264    if matches_hardcoded_patterns(input, terminal_patterns) {
 265        return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
 266    }
 267
 268    if let Some(commands) = extracted_commands {
 269        for command in commands {
 270            if matches_hardcoded_patterns(command, terminal_patterns) {
 271                return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
 272            }
 273        }
 274    }
 275
 276    None
 277}
 278
 279fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
 280    for pattern in patterns {
 281        if pattern.is_match(command) {
 282            return true;
 283        }
 284    }
 285
 286    for expanded in expand_rm_to_single_path_commands(command) {
 287        for pattern in patterns {
 288            if pattern.is_match(&expanded) {
 289                return true;
 290            }
 291        }
 292    }
 293
 294    false
 295}
 296
 297fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
 298    let trimmed = command.trim();
 299
 300    let first_token = trimmed.split_whitespace().next();
 301    if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
 302        return vec![];
 303    }
 304
 305    let parts: Vec<&str> = trimmed.split_whitespace().collect();
 306    let mut flags = Vec::new();
 307    let mut paths = Vec::new();
 308    let mut past_double_dash = false;
 309
 310    for part in parts.iter().skip(1) {
 311        if !past_double_dash && *part == "--" {
 312            past_double_dash = true;
 313            flags.push(*part);
 314            continue;
 315        }
 316        if !past_double_dash && part.starts_with('-') {
 317            flags.push(*part);
 318        } else {
 319            paths.push(*part);
 320        }
 321    }
 322
 323    let flags_str = if flags.is_empty() {
 324        String::new()
 325    } else {
 326        format!("{} ", flags.join(" "))
 327    };
 328
 329    let mut results = Vec::new();
 330    for path in &paths {
 331        if path.starts_with('$') {
 332            let home_prefix = if path.starts_with("${HOME}") {
 333                Some("${HOME}")
 334            } else if path.starts_with("$HOME") {
 335                Some("$HOME")
 336            } else {
 337                None
 338            };
 339
 340            if let Some(prefix) = home_prefix {
 341                let suffix = &path[prefix.len()..];
 342                if suffix.is_empty() {
 343                    results.push(format!("rm {flags_str}{path}"));
 344                } else if suffix.starts_with('/') {
 345                    let normalized_suffix = normalize_path(suffix);
 346                    let reconstructed = if normalized_suffix == "/" {
 347                        prefix.to_string()
 348                    } else {
 349                        format!("{prefix}{normalized_suffix}")
 350                    };
 351                    results.push(format!("rm {flags_str}{reconstructed}"));
 352                } else {
 353                    results.push(format!("rm {flags_str}{path}"));
 354                }
 355            } else {
 356                results.push(format!("rm {flags_str}{path}"));
 357            }
 358            continue;
 359        }
 360
 361        let mut normalized = normalize_path(path);
 362        if normalized.is_empty() && !Path::new(path).has_root() {
 363            normalized = ".".to_string();
 364        }
 365
 366        results.push(format!("rm {flags_str}{normalized}"));
 367    }
 368
 369    results
 370}
 371
 372pub fn normalize_path(raw: &str) -> String {
 373    let is_absolute = Path::new(raw).has_root();
 374    let mut components: Vec<&str> = Vec::new();
 375    for component in Path::new(raw).components() {
 376        match component {
 377            Component::CurDir => {}
 378            Component::ParentDir => {
 379                if components.last() == Some(&"..") {
 380                    components.push("..");
 381                } else if !components.is_empty() {
 382                    components.pop();
 383                } else if !is_absolute {
 384                    components.push("..");
 385                }
 386            }
 387            Component::Normal(segment) => {
 388                if let Some(s) = segment.to_str() {
 389                    components.push(s);
 390                }
 391            }
 392            Component::RootDir | Component::Prefix(_) => {}
 393        }
 394    }
 395    let joined = components.join("/");
 396    if is_absolute {
 397        format!("/{joined}")
 398    } else {
 399        joined
 400    }
 401}
 402
 403impl Settings for AgentSettings {
 404    fn from_settings(content: &settings::SettingsContent) -> Self {
 405        let agent = content.agent.clone().unwrap();
 406        Self {
 407            enabled: agent.enabled.unwrap(),
 408            button: agent.button.unwrap(),
 409            dock: agent.dock.unwrap(),
 410            default_width: px(agent.default_width.unwrap()),
 411            default_height: px(agent.default_height.unwrap()),
 412            default_model: Some(agent.default_model.unwrap()),
 413            inline_assistant_model: agent.inline_assistant_model,
 414            inline_assistant_use_streaming_tools: agent
 415                .inline_assistant_use_streaming_tools
 416                .unwrap_or(true),
 417            commit_message_model: agent.commit_message_model,
 418            thread_summary_model: agent.thread_summary_model,
 419            inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
 420            favorite_models: agent.favorite_models,
 421            default_profile: AgentProfileId(agent.default_profile.unwrap()),
 422            default_view: agent.default_view.unwrap(),
 423            profiles: agent
 424                .profiles
 425                .unwrap()
 426                .into_iter()
 427                .map(|(key, val)| (AgentProfileId(key), val.into()))
 428                .collect(),
 429
 430            notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
 431            play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(),
 432            single_file_review: agent.single_file_review.unwrap(),
 433            model_parameters: agent.model_parameters,
 434            enable_feedback: agent.enable_feedback.unwrap(),
 435            expand_edit_card: agent.expand_edit_card.unwrap(),
 436            expand_terminal_card: agent.expand_terminal_card.unwrap(),
 437            cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(),
 438            use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
 439            message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
 440            show_turn_stats: agent.show_turn_stats.unwrap(),
 441            tool_permissions: compile_tool_permissions(agent.tool_permissions),
 442            new_thread_location: agent.new_thread_location.unwrap_or_default(),
 443        }
 444    }
 445}
 446
 447fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
 448    let Some(content) = content else {
 449        return ToolPermissions::default();
 450    };
 451
 452    let tools = content
 453        .tools
 454        .into_iter()
 455        .map(|(tool_name, rules_content)| {
 456            let mut invalid_patterns = Vec::new();
 457
 458            let (always_allow, allow_errors) = compile_regex_rules(
 459                rules_content.always_allow.map(|v| v.0).unwrap_or_default(),
 460                "always_allow",
 461            );
 462            invalid_patterns.extend(allow_errors);
 463
 464            let (always_deny, deny_errors) = compile_regex_rules(
 465                rules_content.always_deny.map(|v| v.0).unwrap_or_default(),
 466                "always_deny",
 467            );
 468            invalid_patterns.extend(deny_errors);
 469
 470            let (always_confirm, confirm_errors) = compile_regex_rules(
 471                rules_content
 472                    .always_confirm
 473                    .map(|v| v.0)
 474                    .unwrap_or_default(),
 475                "always_confirm",
 476            );
 477            invalid_patterns.extend(confirm_errors);
 478
 479            // Log invalid patterns for debugging. Users will see an error when they
 480            // attempt to use a tool with invalid patterns in their settings.
 481            for invalid in &invalid_patterns {
 482                log::error!(
 483                    "Invalid regex pattern in tool_permissions for '{}' tool ({}): '{}' - {}",
 484                    tool_name,
 485                    invalid.rule_type,
 486                    invalid.pattern,
 487                    invalid.error,
 488                );
 489            }
 490
 491            let rules = ToolRules {
 492                // Preserve tool-specific default; None means fall back to global default at decision time
 493                default: rules_content.default,
 494                always_allow,
 495                always_deny,
 496                always_confirm,
 497                invalid_patterns,
 498            };
 499            (tool_name, rules)
 500        })
 501        .collect();
 502
 503    ToolPermissions {
 504        default: content.default.unwrap_or_default(),
 505        tools,
 506    }
 507}
 508
 509fn compile_regex_rules(
 510    rules: Vec<settings::ToolRegexRule>,
 511    rule_type: &str,
 512) -> (Vec<CompiledRegex>, Vec<InvalidRegexPattern>) {
 513    let mut compiled = Vec::new();
 514    let mut errors = Vec::new();
 515
 516    for rule in rules {
 517        if rule.pattern.is_empty() {
 518            errors.push(InvalidRegexPattern {
 519                pattern: rule.pattern,
 520                rule_type: rule_type.to_string(),
 521                error: "empty regex patterns are not allowed".to_string(),
 522            });
 523            continue;
 524        }
 525        let case_sensitive = rule.case_sensitive.unwrap_or(false);
 526        match CompiledRegex::try_new(&rule.pattern, case_sensitive) {
 527            Ok(regex) => compiled.push(regex),
 528            Err(error) => {
 529                errors.push(InvalidRegexPattern {
 530                    pattern: rule.pattern,
 531                    rule_type: rule_type.to_string(),
 532                    error: error.to_string(),
 533                });
 534            }
 535        }
 536    }
 537
 538    (compiled, errors)
 539}
 540
 541#[cfg(test)]
 542mod tests {
 543    use super::*;
 544    use serde_json::json;
 545    use settings::ToolPermissionMode;
 546    use settings::ToolPermissionsContent;
 547
 548    #[test]
 549    fn test_compiled_regex_case_insensitive() {
 550        let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
 551        assert!(regex.is_match("rm -rf /"));
 552        assert!(regex.is_match("RM -RF /"));
 553        assert!(regex.is_match("Rm -Rf /"));
 554    }
 555
 556    #[test]
 557    fn test_compiled_regex_case_sensitive() {
 558        let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
 559        assert!(regex.is_match("DROP TABLE users"));
 560        assert!(!regex.is_match("drop table users"));
 561    }
 562
 563    #[test]
 564    fn test_invalid_regex_returns_none() {
 565        let result = CompiledRegex::new("[invalid(regex", false);
 566        assert!(result.is_none());
 567    }
 568
 569    #[test]
 570    fn test_tool_permissions_parsing() {
 571        let json = json!({
 572            "tools": {
 573                "terminal": {
 574                    "default": "allow",
 575                    "always_deny": [
 576                        { "pattern": "rm\\s+-rf" }
 577                    ],
 578                    "always_allow": [
 579                        { "pattern": "^git\\s" }
 580                    ]
 581                }
 582            }
 583        });
 584
 585        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 586        let permissions = compile_tool_permissions(Some(content));
 587
 588        let terminal_rules = permissions.tools.get("terminal").unwrap();
 589        assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
 590        assert_eq!(terminal_rules.always_deny.len(), 1);
 591        assert_eq!(terminal_rules.always_allow.len(), 1);
 592        assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
 593        assert!(terminal_rules.always_allow[0].is_match("git status"));
 594    }
 595
 596    #[test]
 597    fn test_tool_rules_default() {
 598        let json = json!({
 599            "tools": {
 600                "edit_file": {
 601                    "default": "deny"
 602                }
 603            }
 604        });
 605
 606        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 607        let permissions = compile_tool_permissions(Some(content));
 608
 609        let rules = permissions.tools.get("edit_file").unwrap();
 610        assert_eq!(rules.default, Some(ToolPermissionMode::Deny));
 611    }
 612
 613    #[test]
 614    fn test_tool_permissions_empty() {
 615        let permissions = compile_tool_permissions(None);
 616        assert!(permissions.tools.is_empty());
 617        assert_eq!(permissions.default, ToolPermissionMode::Confirm);
 618    }
 619
 620    #[test]
 621    fn test_tool_rules_default_returns_confirm() {
 622        let default_rules = ToolRules::default();
 623        assert_eq!(default_rules.default, None);
 624        assert!(default_rules.always_allow.is_empty());
 625        assert!(default_rules.always_deny.is_empty());
 626        assert!(default_rules.always_confirm.is_empty());
 627    }
 628
 629    #[test]
 630    fn test_tool_permissions_with_multiple_tools() {
 631        let json = json!({
 632            "tools": {
 633                "terminal": {
 634                    "default": "allow",
 635                    "always_deny": [{ "pattern": "rm\\s+-rf" }]
 636                },
 637                "edit_file": {
 638                    "default": "confirm",
 639                    "always_deny": [{ "pattern": "\\.env$" }]
 640                },
 641                "delete_path": {
 642                    "default": "deny"
 643                }
 644            }
 645        });
 646
 647        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 648        let permissions = compile_tool_permissions(Some(content));
 649
 650        assert_eq!(permissions.tools.len(), 3);
 651
 652        let terminal = permissions.tools.get("terminal").unwrap();
 653        assert_eq!(terminal.default, Some(ToolPermissionMode::Allow));
 654        assert_eq!(terminal.always_deny.len(), 1);
 655
 656        let edit_file = permissions.tools.get("edit_file").unwrap();
 657        assert_eq!(edit_file.default, Some(ToolPermissionMode::Confirm));
 658        assert!(edit_file.always_deny[0].is_match("secrets.env"));
 659
 660        let delete_path = permissions.tools.get("delete_path").unwrap();
 661        assert_eq!(delete_path.default, Some(ToolPermissionMode::Deny));
 662    }
 663
 664    #[test]
 665    fn test_tool_permissions_with_all_rule_types() {
 666        let json = json!({
 667            "tools": {
 668                "terminal": {
 669                    "always_deny": [{ "pattern": "rm\\s+-rf" }],
 670                    "always_confirm": [{ "pattern": "sudo\\s" }],
 671                    "always_allow": [{ "pattern": "^git\\s+status" }]
 672                }
 673            }
 674        });
 675
 676        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 677        let permissions = compile_tool_permissions(Some(content));
 678
 679        let terminal = permissions.tools.get("terminal").unwrap();
 680        assert_eq!(terminal.always_deny.len(), 1);
 681        assert_eq!(terminal.always_confirm.len(), 1);
 682        assert_eq!(terminal.always_allow.len(), 1);
 683
 684        assert!(terminal.always_deny[0].is_match("rm -rf /"));
 685        assert!(terminal.always_confirm[0].is_match("sudo apt install"));
 686        assert!(terminal.always_allow[0].is_match("git status"));
 687    }
 688
 689    #[test]
 690    fn test_invalid_regex_is_tracked_and_valid_ones_still_compile() {
 691        let json = json!({
 692            "tools": {
 693                "terminal": {
 694                    "always_deny": [
 695                        { "pattern": "[invalid(regex" },
 696                        { "pattern": "valid_pattern" }
 697                    ],
 698                    "always_allow": [
 699                        { "pattern": "[another_bad" }
 700                    ]
 701                }
 702            }
 703        });
 704
 705        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 706        let permissions = compile_tool_permissions(Some(content));
 707
 708        let terminal = permissions.tools.get("terminal").unwrap();
 709
 710        // Valid patterns should still be compiled
 711        assert_eq!(terminal.always_deny.len(), 1);
 712        assert!(terminal.always_deny[0].is_match("valid_pattern"));
 713
 714        // Invalid patterns should be tracked (order depends on processing order)
 715        assert_eq!(terminal.invalid_patterns.len(), 2);
 716
 717        let deny_invalid = terminal
 718            .invalid_patterns
 719            .iter()
 720            .find(|p| p.rule_type == "always_deny")
 721            .expect("should have invalid pattern from always_deny");
 722        assert_eq!(deny_invalid.pattern, "[invalid(regex");
 723        assert!(!deny_invalid.error.is_empty());
 724
 725        let allow_invalid = terminal
 726            .invalid_patterns
 727            .iter()
 728            .find(|p| p.rule_type == "always_allow")
 729            .expect("should have invalid pattern from always_allow");
 730        assert_eq!(allow_invalid.pattern, "[another_bad");
 731
 732        // ToolPermissions helper methods should work
 733        assert!(permissions.has_invalid_patterns());
 734        assert_eq!(permissions.invalid_patterns().len(), 2);
 735    }
 736
 737    #[test]
 738    fn test_deny_takes_precedence_over_allow_and_confirm() {
 739        let json = json!({
 740            "tools": {
 741                "terminal": {
 742                    "default": "allow",
 743                    "always_deny": [{ "pattern": "dangerous" }],
 744                    "always_confirm": [{ "pattern": "dangerous" }],
 745                    "always_allow": [{ "pattern": "dangerous" }]
 746                }
 747            }
 748        });
 749
 750        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 751        let permissions = compile_tool_permissions(Some(content));
 752        let terminal = permissions.tools.get("terminal").unwrap();
 753
 754        assert!(
 755            terminal.always_deny[0].is_match("run dangerous command"),
 756            "Deny rule should match"
 757        );
 758        assert!(
 759            terminal.always_allow[0].is_match("run dangerous command"),
 760            "Allow rule should also match (but deny takes precedence at evaluation time)"
 761        );
 762        assert!(
 763            terminal.always_confirm[0].is_match("run dangerous command"),
 764            "Confirm rule should also match (but deny takes precedence at evaluation time)"
 765        );
 766    }
 767
 768    #[test]
 769    fn test_confirm_takes_precedence_over_allow() {
 770        let json = json!({
 771            "tools": {
 772                "terminal": {
 773                    "default": "allow",
 774                    "always_confirm": [{ "pattern": "risky" }],
 775                    "always_allow": [{ "pattern": "risky" }]
 776                }
 777            }
 778        });
 779
 780        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 781        let permissions = compile_tool_permissions(Some(content));
 782        let terminal = permissions.tools.get("terminal").unwrap();
 783
 784        assert!(
 785            terminal.always_confirm[0].is_match("do risky thing"),
 786            "Confirm rule should match"
 787        );
 788        assert!(
 789            terminal.always_allow[0].is_match("do risky thing"),
 790            "Allow rule should also match (but confirm takes precedence at evaluation time)"
 791        );
 792    }
 793
 794    #[test]
 795    fn test_regex_matches_anywhere_in_string_not_just_anchored() {
 796        let json = json!({
 797            "tools": {
 798                "terminal": {
 799                    "always_deny": [
 800                        { "pattern": "rm\\s+-rf" },
 801                        { "pattern": "/etc/passwd" }
 802                    ]
 803                }
 804            }
 805        });
 806
 807        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 808        let permissions = compile_tool_permissions(Some(content));
 809        let terminal = permissions.tools.get("terminal").unwrap();
 810
 811        assert!(
 812            terminal.always_deny[0].is_match("echo hello && rm -rf /"),
 813            "Should match rm -rf in the middle of a command chain"
 814        );
 815        assert!(
 816            terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
 817            "Should match rm -rf after semicolon"
 818        );
 819        assert!(
 820            terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
 821            "Should match /etc/passwd in a pipeline"
 822        );
 823        assert!(
 824            terminal.always_deny[1].is_match("vim /etc/passwd"),
 825            "Should match /etc/passwd as argument"
 826        );
 827    }
 828
 829    #[test]
 830    fn test_fork_bomb_pattern_matches() {
 831        let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
 832        assert!(
 833            fork_bomb_regex.is_match(":(){ :|:& };:"),
 834            "Should match the classic fork bomb"
 835        );
 836        assert!(
 837            fork_bomb_regex.is_match(":(){ :|:&};:"),
 838            "Should match fork bomb without spaces"
 839        );
 840    }
 841
 842    #[test]
 843    fn test_compiled_regex_stores_case_sensitivity() {
 844        let case_sensitive = CompiledRegex::new("test", true).unwrap();
 845        let case_insensitive = CompiledRegex::new("test", false).unwrap();
 846
 847        assert!(case_sensitive.case_sensitive);
 848        assert!(!case_insensitive.case_sensitive);
 849    }
 850
 851    #[test]
 852    fn test_invalid_regex_is_skipped_not_fail() {
 853        let json = json!({
 854            "tools": {
 855                "terminal": {
 856                    "always_deny": [
 857                        { "pattern": "[invalid(regex" },
 858                        { "pattern": "valid_pattern" }
 859                    ]
 860                }
 861            }
 862        });
 863
 864        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 865        let permissions = compile_tool_permissions(Some(content));
 866
 867        let terminal = permissions.tools.get("terminal").unwrap();
 868        assert_eq!(terminal.always_deny.len(), 1);
 869        assert!(terminal.always_deny[0].is_match("valid_pattern"));
 870    }
 871
 872    #[test]
 873    fn test_unconfigured_tool_not_in_permissions() {
 874        let json = json!({
 875            "tools": {
 876                "terminal": {
 877                    "default": "allow"
 878                }
 879            }
 880        });
 881
 882        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 883        let permissions = compile_tool_permissions(Some(content));
 884
 885        assert!(permissions.tools.contains_key("terminal"));
 886        assert!(!permissions.tools.contains_key("edit_file"));
 887        assert!(!permissions.tools.contains_key("fetch"));
 888    }
 889
 890    #[test]
 891    fn test_always_allow_pattern_only_matches_specified_commands() {
 892        // Reproduces user-reported bug: when always_allow has pattern "^echo\s",
 893        // only "echo hello" should be allowed, not "git status".
 894        //
 895        // User config:
 896        //   always_allow_tool_actions: false
 897        //   tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
 898        let json = json!({
 899            "tools": {
 900                "terminal": {
 901                    "always_allow": [
 902                        { "pattern": "^echo\\s" }
 903                    ]
 904                }
 905            }
 906        });
 907
 908        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 909        let permissions = compile_tool_permissions(Some(content));
 910
 911        let terminal = permissions.tools.get("terminal").unwrap();
 912
 913        // Verify the pattern was compiled
 914        assert_eq!(
 915            terminal.always_allow.len(),
 916            1,
 917            "Should have one always_allow pattern"
 918        );
 919
 920        // Verify the pattern matches "echo hello"
 921        assert!(
 922            terminal.always_allow[0].is_match("echo hello"),
 923            "Pattern ^echo\\s should match 'echo hello'"
 924        );
 925
 926        // Verify the pattern does NOT match "git status"
 927        assert!(
 928            !terminal.always_allow[0].is_match("git status"),
 929            "Pattern ^echo\\s should NOT match 'git status'"
 930        );
 931
 932        // Verify the pattern does NOT match "echoHello" (no space)
 933        assert!(
 934            !terminal.always_allow[0].is_match("echoHello"),
 935            "Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
 936        );
 937
 938        assert_eq!(
 939            terminal.default, None,
 940            "default should be None when not specified"
 941        );
 942    }
 943
 944    #[test]
 945    fn test_empty_regex_pattern_is_invalid() {
 946        let json = json!({
 947            "tools": {
 948                "terminal": {
 949                    "always_allow": [
 950                        { "pattern": "" }
 951                    ],
 952                    "always_deny": [
 953                        { "case_sensitive": true }
 954                    ],
 955                    "always_confirm": [
 956                        { "pattern": "" },
 957                        { "pattern": "valid_pattern" }
 958                    ]
 959                }
 960            }
 961        });
 962
 963        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
 964        let permissions = compile_tool_permissions(Some(content));
 965
 966        let terminal = permissions.tools.get("terminal").unwrap();
 967
 968        assert_eq!(terminal.always_allow.len(), 0);
 969        assert_eq!(terminal.always_deny.len(), 0);
 970        assert_eq!(terminal.always_confirm.len(), 1);
 971        assert!(terminal.always_confirm[0].is_match("valid_pattern"));
 972
 973        assert_eq!(terminal.invalid_patterns.len(), 3);
 974        for invalid in &terminal.invalid_patterns {
 975            assert_eq!(invalid.pattern, "");
 976            assert!(invalid.error.contains("empty"));
 977        }
 978    }
 979
 980    #[test]
 981    fn test_default_json_tool_permissions_parse() {
 982        let default_json = include_str!("../../../assets/settings/default.json");
 983        let value: serde_json_lenient::Value = serde_json_lenient::from_str(default_json).unwrap();
 984        let agent = value
 985            .get("agent")
 986            .expect("default.json should have 'agent' key");
 987        let tool_permissions_value = agent
 988            .get("tool_permissions")
 989            .expect("agent should have 'tool_permissions' key");
 990
 991        let content: ToolPermissionsContent =
 992            serde_json_lenient::from_value(tool_permissions_value.clone()).unwrap();
 993        let permissions = compile_tool_permissions(Some(content));
 994
 995        assert_eq!(permissions.default, ToolPermissionMode::Confirm);
 996
 997        assert!(
 998            permissions.tools.is_empty(),
 999            "default.json should not have any active tool-specific rules, found: {:?}",
1000            permissions.tools.keys().collect::<Vec<_>>()
1001        );
1002    }
1003
1004    #[test]
1005    fn test_tool_permissions_explicit_global_default() {
1006        let json_allow = json!({
1007            "default": "allow"
1008        });
1009        let content: ToolPermissionsContent = serde_json::from_value(json_allow).unwrap();
1010        let permissions = compile_tool_permissions(Some(content));
1011        assert_eq!(permissions.default, ToolPermissionMode::Allow);
1012
1013        let json_deny = json!({
1014            "default": "deny"
1015        });
1016        let content: ToolPermissionsContent = serde_json::from_value(json_deny).unwrap();
1017        let permissions = compile_tool_permissions(Some(content));
1018        assert_eq!(permissions.default, ToolPermissionMode::Deny);
1019    }
1020}