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