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