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