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