Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T02:55:23.842Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

agent_settings.rs

1881 lines · 69.5 KB · rust
1mod agent_profile;
2mod user_agents_md;
3
4use std::cmp::Ordering::{Equal, Greater, Less};
5use std::fmt;
6use std::path::{Component, Path, PathBuf};
7use std::sync::{Arc, LazyLock};
8
9use anyhow::Context as _;
10use collections::{HashSet, IndexMap};
11use fs::Fs;
12use futures::channel::oneshot;
13use gpui::{App, Pixels, SharedString, px};
14use language_model::LanguageModel;
15use project::DisableAiSettings;
16use schemars::JsonSchema;
17use serde::{Deserialize, Serialize};
18use settings::{
19    DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection,
20    NotifyWhenAgentWaiting, PlaySoundWhenAgentDone, RegisterSetting, Settings, SettingsContent,
21    SettingsStore, SidebarDockPosition, SidebarSide, ThinkingBlockDisplay, ToolPermissionMode,
22    update_settings_file, update_settings_file_with_completion,
23};
24use util::ResultExt as _;
25
26pub use crate::agent_profile::*;
27pub use crate::user_agents_md::{UserAgentsMd, UserAgentsMdState, init as init_user_agents_md};
28
29pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt");
30pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
31    include_str!("prompts/summarize_thread_detailed_prompt.txt");
32pub const COMPACTION_PROMPT: &str = include_str!("prompts/compaction_prompt.txt");
33
34#[derive(Debug, Clone, Default, PartialEq, Eq)]
35pub struct PanelLayout {
36    pub(crate) agent_dock: Option<DockPosition>,
37    pub(crate) project_panel_dock: Option<DockSide>,
38    pub(crate) outline_panel_dock: Option<DockSide>,
39    pub(crate) collaboration_panel_dock: Option<DockPosition>,
40    pub(crate) git_panel_dock: Option<DockPosition>,
41}
42
43impl PanelLayout {
44    const AGENT: Self = Self {
45        agent_dock: Some(DockPosition::Left),
46        project_panel_dock: Some(DockSide::Right),
47        outline_panel_dock: Some(DockSide::Right),
48        collaboration_panel_dock: Some(DockPosition::Right),
49        git_panel_dock: Some(DockPosition::Right),
50    };
51
52    const EDITOR: Self = Self {
53        agent_dock: Some(DockPosition::Right),
54        project_panel_dock: Some(DockSide::Left),
55        outline_panel_dock: Some(DockSide::Left),
56        collaboration_panel_dock: Some(DockPosition::Left),
57        git_panel_dock: Some(DockPosition::Left),
58    };
59
60    pub fn is_agent_layout(&self) -> bool {
61        *self == Self::AGENT
62    }
63
64    pub fn is_editor_layout(&self) -> bool {
65        *self == Self::EDITOR
66    }
67
68    fn read_from(content: &SettingsContent) -> Self {
69        Self {
70            agent_dock: content.agent.as_ref().and_then(|a| a.dock),
71            project_panel_dock: content.project_panel.as_ref().and_then(|p| p.dock),
72            outline_panel_dock: content.outline_panel.as_ref().and_then(|p| p.dock),
73            collaboration_panel_dock: content.collaboration_panel.as_ref().and_then(|p| p.dock),
74            git_panel_dock: content.git_panel.as_ref().and_then(|p| p.dock),
75        }
76    }
77
78    fn write_to(&self, settings: &mut SettingsContent) {
79        settings.agent.get_or_insert_default().dock = self.agent_dock;
80        settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
81        settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
82        settings.collaboration_panel.get_or_insert_default().dock = self.collaboration_panel_dock;
83        settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
84    }
85
86    fn write_diff_to(&self, current_merged: &PanelLayout, settings: &mut SettingsContent) {
87        if self.agent_dock != current_merged.agent_dock {
88            settings.agent.get_or_insert_default().dock = self.agent_dock;
89        }
90        if self.project_panel_dock != current_merged.project_panel_dock {
91            settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
92        }
93        if self.outline_panel_dock != current_merged.outline_panel_dock {
94            settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
95        }
96        if self.collaboration_panel_dock != current_merged.collaboration_panel_dock {
97            settings.collaboration_panel.get_or_insert_default().dock =
98                self.collaboration_panel_dock;
99        }
100        if self.git_panel_dock != current_merged.git_panel_dock {
101            settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
102        }
103    }
104
105    fn backfill_to(&self, user_layout: &PanelLayout, settings: &mut SettingsContent) {
106        if user_layout.agent_dock.is_none() {
107            settings.agent.get_or_insert_default().dock = self.agent_dock;
108        }
109        if user_layout.project_panel_dock.is_none() {
110            settings.project_panel.get_or_insert_default().dock = self.project_panel_dock;
111        }
112        if user_layout.outline_panel_dock.is_none() {
113            settings.outline_panel.get_or_insert_default().dock = self.outline_panel_dock;
114        }
115        if user_layout.collaboration_panel_dock.is_none() {
116            settings.collaboration_panel.get_or_insert_default().dock =
117                self.collaboration_panel_dock;
118        }
119        if user_layout.git_panel_dock.is_none() {
120            settings.git_panel.get_or_insert_default().dock = self.git_panel_dock;
121        }
122    }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq)]
126pub enum WindowLayout {
127    Editor(Option<PanelLayout>),
128    Agent(Option<PanelLayout>),
129    Custom(PanelLayout),
130}
131
132impl WindowLayout {
133    pub fn agent() -> Self {
134        Self::Agent(None)
135    }
136
137    pub fn editor() -> Self {
138        Self::Editor(None)
139    }
140}
141
142#[derive(Clone, Copy, Debug, PartialEq)]
143pub enum AutoCompactThreshold {
144    /// Compact once the context window is at least this full, as a fraction in
145    /// the range `(0.0, 1.0]`.
146    Percentage(f64),
147    /// Compact once at least this many tokens have been used.
148    TokensUsed(u64),
149    /// Compact once fewer than this many tokens remain in the context window.
150    TokensRemaining(u64),
151}
152
153impl AutoCompactThreshold {
154    /// The threshold used when none is configured, or when the configured value
155    /// is invalid (90% of the context window).
156    pub const DEFAULT: Self = Self::Percentage(0.9);
157}
158
159impl fmt::Display for AutoCompactThreshold {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        match self {
162            Self::Percentage(percent) => write!(formatter, "{}%", percent * 100.0),
163            Self::TokensUsed(tokens) => write!(formatter, "{tokens}"),
164            Self::TokensRemaining(tokens) => write!(formatter, "-{tokens}"),
165        }
166    }
167}
168
169#[derive(Clone, Copy, Debug, PartialEq)]
170pub struct AutoCompactSettings {
171    pub enabled: bool,
172    pub threshold: AutoCompactThreshold,
173}
174
175fn parse_auto_compact_threshold(raw: &str) -> anyhow::Result<AutoCompactThreshold> {
176    let trimmed = raw.trim();
177    if let Some(percent) = trimmed.strip_suffix('%') {
178        let value: f64 = percent
179            .trim_end()
180            .parse()
181            .with_context(|| format!("invalid auto_compact threshold percentage {raw:?}"))?;
182        anyhow::ensure!(
183            value > 0.0 && value <= 100.0,
184            "auto_compact threshold percentage must be between 0% and 100%, got {raw:?}"
185        );
186        Ok(AutoCompactThreshold::Percentage(value / 100.0))
187    } else {
188        let tokens: i64 = trimmed.parse().with_context(|| {
189            format!(
190                "invalid auto_compact threshold {raw:?}; \
191                 expected a percentage like \"90%\" or an integer number of tokens"
192            )
193        })?;
194        match tokens.cmp(&0) {
195            Greater => Ok(AutoCompactThreshold::TokensUsed(tokens as u64)),
196            Less => Ok(AutoCompactThreshold::TokensRemaining(tokens.unsigned_abs())),
197            Equal => {
198                anyhow::bail!("auto_compact threshold of 0 is not valid")
199            }
200        }
201    }
202}
203
204#[derive(Clone, Debug, RegisterSetting)]
205pub struct AgentSettings {
206    pub enabled: bool,
207    pub button: bool,
208    pub dock: DockPosition,
209    pub flexible: bool,
210    pub sidebar_side: SidebarDockPosition,
211    pub default_width: Pixels,
212    pub default_height: Pixels,
213    pub max_content_width: Option<Pixels>,
214    pub default_model: Option<LanguageModelSelection>,
215    pub subagent_model: Option<LanguageModelSelection>,
216    pub inline_assistant_model: Option<LanguageModelSelection>,
217    pub inline_assistant_use_streaming_tools: bool,
218    pub commit_message_model: Option<LanguageModelSelection>,
219    pub commit_message_include_project_rules: bool,
220    pub commit_message_instructions: Option<String>,
221    pub thread_summary_model: Option<LanguageModelSelection>,
222    pub compaction_model: Option<LanguageModelSelection>,
223    pub inline_alternatives: Vec<LanguageModelSelection>,
224    pub favorite_models: Vec<LanguageModelSelection>,
225    pub default_profile: AgentProfileId,
226    pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
227
228    pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
229    pub play_sound_when_agent_done: PlaySoundWhenAgentDone,
230    pub single_file_review: bool,
231    pub model_parameters: Vec<LanguageModelParameters>,
232    pub auto_compact: AutoCompactSettings,
233    pub enable_feedback: bool,
234    pub expand_edit_card: bool,
235    pub expand_terminal_card: bool,
236    pub terminal_init_command: Option<String>,
237    pub thinking_display: ThinkingBlockDisplay,
238    pub cancel_generation_on_terminal_stop: bool,
239    pub use_modifier_to_send: bool,
240    pub message_editor_min_lines: usize,
241    pub show_turn_stats: bool,
242    pub show_merge_conflict_indicator: bool,
243    pub tool_permissions: ToolPermissions,
244    pub sandbox_permissions: SandboxPermissions,
245}
246
247impl AgentSettings {
248    pub fn enabled(&self, cx: &App) -> bool {
249        self.enabled && !DisableAiSettings::get_global(cx).disable_ai
250    }
251
252    pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
253        let settings = Self::get_global(cx);
254        for setting in settings.model_parameters.iter().rev() {
255            if let Some(provider) = &setting.provider
256                && provider.0 != model.provider_id().0
257            {
258                continue;
259            }
260            if let Some(setting_model) = &setting.model
261                && *setting_model != model.id().0
262            {
263                continue;
264            }
265            return setting.temperature;
266        }
267        return None;
268    }
269
270    pub fn sidebar_side(&self) -> SidebarSide {
271        match self.sidebar_side {
272            SidebarDockPosition::Left => SidebarSide::Left,
273            SidebarDockPosition::Right => SidebarSide::Right,
274        }
275    }
276
277    pub fn set_message_editor_max_lines(&self) -> usize {
278        self.message_editor_min_lines * 2
279    }
280
281    pub fn favorite_model_ids(&self) -> HashSet<SharedString> {
282        self.favorite_models
283            .iter()
284            .map(|sel| SharedString::from(format!("{}/{}", sel.provider.0, sel.model)))
285            .collect()
286    }
287}
288
289pub fn language_model_to_selection(
290    model: &Arc<dyn LanguageModel>,
291    override_selection: Option<&LanguageModelSelection>,
292) -> LanguageModelSelection {
293    let provider = model.provider_id().0.to_string().into();
294    let model_name = model.id().0.to_string();
295    match override_selection {
296        Some(current) => LanguageModelSelection {
297            provider,
298            model: model_name,
299            enable_thinking: current.enable_thinking && model.supports_thinking(),
300            effort: current
301                .effort
302                .clone()
303                .filter(|value| {
304                    model
305                        .supported_effort_levels()
306                        .iter()
307                        .any(|level| level.value.as_ref() == value.as_str())
308                })
309                .or_else(|| {
310                    model
311                        .default_effort_level()
312                        .map(|effort| effort.value.to_string())
313                }),
314            speed: current.speed.filter(|_| model.supports_fast_mode()),
315        },
316        None => LanguageModelSelection {
317            provider,
318            model: model_name,
319            enable_thinking: model.supports_thinking(),
320            effort: model
321                .default_effort_level()
322                .map(|effort| effort.value.to_string()),
323            speed: None,
324        },
325    }
326}
327
328impl AgentSettings {
329    pub fn get_layout(cx: &App) -> WindowLayout {
330        let store = cx.global::<SettingsStore>();
331        let merged = store.merged_settings();
332        let user_layout = store
333            .raw_user_settings()
334            .map(|u| PanelLayout::read_from(u.content.as_ref()))
335            .unwrap_or_default();
336        let merged_layout = PanelLayout::read_from(merged);
337
338        if merged_layout.is_agent_layout() {
339            return WindowLayout::Agent(Some(user_layout));
340        }
341
342        if merged_layout.is_editor_layout() {
343            return WindowLayout::Editor(Some(user_layout));
344        }
345
346        WindowLayout::Custom(user_layout)
347    }
348
349    pub fn backfill_editor_layout(fs: Arc<dyn Fs>, cx: &App) {
350        let user_layout = cx
351            .global::<SettingsStore>()
352            .raw_user_settings()
353            .map(|u| PanelLayout::read_from(u.content.as_ref()))
354            .unwrap_or_default();
355
356        update_settings_file(fs, cx, move |settings, _cx| {
357            PanelLayout::EDITOR.backfill_to(&user_layout, settings);
358        });
359    }
360
361    pub fn set_layout(
362        layout: WindowLayout,
363        fs: Arc<dyn Fs>,
364        cx: &App,
365    ) -> oneshot::Receiver<anyhow::Result<()>> {
366        let merged = PanelLayout::read_from(cx.global::<SettingsStore>().merged_settings());
367
368        match layout {
369            WindowLayout::Agent(None) => {
370                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
371                    PanelLayout::AGENT.write_diff_to(&merged, settings);
372                })
373            }
374            WindowLayout::Editor(None) => {
375                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
376                    PanelLayout::EDITOR.write_diff_to(&merged, settings);
377                })
378            }
379            WindowLayout::Agent(Some(saved))
380            | WindowLayout::Editor(Some(saved))
381            | WindowLayout::Custom(saved) => {
382                update_settings_file_with_completion(fs, cx, move |settings, _cx| {
383                    saved.write_to(settings);
384                })
385            }
386        }
387    }
388}
389
390#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
391pub struct AgentProfileId(pub Arc<str>);
392
393impl AgentProfileId {
394    pub fn as_str(&self) -> &str {
395        &self.0
396    }
397}
398
399impl std::fmt::Display for AgentProfileId {
400    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
401        write!(f, "{}", self.0)
402    }
403}
404
405impl Default for AgentProfileId {
406    fn default() -> Self {
407        Self("write".into())
408    }
409}
410
411/// Persistent "allow always" sandbox grants for agent-run terminal commands.
412///
413/// Coverage decisions for these grants are made in
414/// `agent::sandboxing::ThreadSandboxGrants::covers_with_persistent`, which
415/// combines them with the in-memory per-thread grants. `write_paths` are
416/// stored as minimal, lexically-normalized subtrees (see
417/// [`compile_sandbox_permissions`]).
418#[derive(Clone, Debug, PartialEq, Eq)]
419pub struct SandboxPermissions {
420    /// Allow sandboxed commands to reach any host over the network.
421    pub allow_all_hosts: bool,
422    /// Hosts sandboxed commands may always reach, in canonical form (exact
423    /// hostnames or leading-`*.` subdomain wildcards). Parsed/validated where
424    /// consumed (`agent::sandboxing`).
425    pub network_hosts: Vec<String>,
426    pub allow_fs_write_all: bool,
427    /// Persistently run agent terminal commands outside the OS sandbox. This is
428    /// the model-facing "off switch": when set, the sandboxed terminal tool is
429    /// not exposed and the system prompt omits the sandbox section, so the
430    /// model uses the plain `terminal` tool (on Windows, WSL sandbox setup is
431    /// skipped). Distinct from the model-requested `unsandboxed: true` escape
432    /// approved "once" or "for this thread", which keeps the sandboxed
433    /// tool/prompt in place — see `agent::sandboxing`.
434    pub allow_unsandboxed: bool,
435    pub write_paths: Vec<PathBuf>,
436    /// Whether sandbox escalation prompts warn about domains or write paths
437    /// that contain potentially confusable Unicode characters (homoglyphs,
438    /// invisible characters, or bidirectional overrides). Enabled by default.
439    pub warn_confusable_unicode: bool,
440}
441
442impl Default for SandboxPermissions {
443    fn default() -> Self {
444        Self {
445            allow_all_hosts: false,
446            network_hosts: Vec::new(),
447            allow_fs_write_all: false,
448            allow_unsandboxed: false,
449            write_paths: Vec::new(),
450            // The confusable-Unicode warning is a safety net, so it defaults on.
451            warn_confusable_unicode: true,
452        }
453    }
454}
455
456#[derive(Clone, Debug, Default)]
457pub struct ToolPermissions {
458    /// Global default permission when no tool-specific rules or patterns match.
459    pub default: ToolPermissionMode,
460    pub tools: collections::HashMap<Arc<str>, ToolRules>,
461}
462
463impl ToolPermissions {
464    /// Returns all invalid regex patterns across all tools.
465    pub fn invalid_patterns(&self) -> Vec<&InvalidRegexPattern> {
466        self.tools
467            .values()
468            .flat_map(|rules| rules.invalid_patterns.iter())
469            .collect()
470    }
471
472    /// Returns true if any tool has invalid regex patterns.
473    pub fn has_invalid_patterns(&self) -> bool {
474        self.tools
475            .values()
476            .any(|rules| !rules.invalid_patterns.is_empty())
477    }
478}
479
480/// Represents a regex pattern that failed to compile.
481#[derive(Clone, Debug)]
482pub struct InvalidRegexPattern {
483    /// The pattern string that failed to compile.
484    pub pattern: String,
485    /// Which rule list this pattern was in (e.g., "always_deny", "always_allow", "always_confirm").
486    pub rule_type: String,
487    /// The error message from the regex compiler.
488    pub error: String,
489}
490
491#[derive(Clone, Debug, Default)]
492pub struct ToolRules {
493    pub default: Option<ToolPermissionMode>,
494    pub always_allow: Vec<CompiledRegex>,
495    pub always_deny: Vec<CompiledRegex>,
496    pub always_confirm: Vec<CompiledRegex>,
497    /// Patterns that failed to compile. If non-empty, tool calls should be blocked.
498    pub invalid_patterns: Vec<InvalidRegexPattern>,
499}
500
501#[derive(Clone)]
502pub struct CompiledRegex {
503    pub pattern: String,
504    pub case_sensitive: bool,
505    pub regex: regex::Regex,
506}
507
508impl std::fmt::Debug for CompiledRegex {
509    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
510        f.debug_struct("CompiledRegex")
511            .field("pattern", &self.pattern)
512            .field("case_sensitive", &self.case_sensitive)
513            .finish()
514    }
515}
516
517impl CompiledRegex {
518    pub fn new(pattern: &str, case_sensitive: bool) -> Option<Self> {
519        Self::try_new(pattern, case_sensitive).ok()
520    }
521
522    pub fn try_new(pattern: &str, case_sensitive: bool) -> Result<Self, regex::Error> {
523        let regex = regex::RegexBuilder::new(pattern)
524            .case_insensitive(!case_sensitive)
525            .build()?;
526        Ok(Self {
527            pattern: pattern.to_string(),
528            case_sensitive,
529            regex,
530        })
531    }
532
533    pub fn is_match(&self, input: &str) -> bool {
534        self.regex.is_match(input)
535    }
536}
537
538pub const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \
539     harmful to be allowed, and cannot be overridden by settings.";
540
541/// Security rules that are always enforced and cannot be overridden by any setting.
542/// These protect against catastrophic operations like wiping filesystems.
543pub struct HardcodedSecurityRules {
544    pub terminal_deny: Vec<CompiledRegex>,
545}
546
547pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {
548    const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";
549    const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";
550
551    HardcodedSecurityRules {
552        terminal_deny: vec![
553            // Recursive deletion of root - "rm -rf /", "rm -rf /*"
554            CompiledRegex::new(
555                &format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),
556                false,
557            )
558            .expect("hardcoded regex should compile"),
559            // Recursive deletion of home via tilde - "rm -rf ~", "rm -rf ~/"
560            CompiledRegex::new(
561                &format!(r"\brm\s+{FLAGS}(--\s+)?~/?\*?{TRAILING_FLAGS}$"),
562                false,
563            )
564            .expect("hardcoded regex should compile"),
565            // Recursive deletion of home via env var - "rm -rf $HOME", "rm -rf ${HOME}"
566            CompiledRegex::new(
567                &format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),
568                false,
569            )
570            .expect("hardcoded regex should compile"),
571            // Recursive deletion of current directory - "rm -rf .", "rm -rf ./"
572            CompiledRegex::new(
573                &format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),
574                false,
575            )
576            .expect("hardcoded regex should compile"),
577            // Recursive deletion of parent directory - "rm -rf ..", "rm -rf ../"
578            CompiledRegex::new(
579                &format!(r"\brm\s+{FLAGS}(--\s+)?\.\./?\*?{TRAILING_FLAGS}$"),
580                false,
581            )
582            .expect("hardcoded regex should compile"),
583        ],
584    }
585});
586
587/// Checks if input matches any hardcoded security rules that cannot be bypassed.
588/// Returns the denial reason string if blocked, None otherwise.
589///
590/// `terminal_tool_name` should be the tool name used for the terminal tool
591/// (e.g. `"terminal"`). `extracted_commands` can optionally provide parsed
592/// sub-commands for chained command checking; callers with access to a shell
593/// parser should extract sub-commands and pass them here.
594pub fn check_hardcoded_security_rules(
595    tool_name: &str,
596    terminal_tool_name: &str,
597    input: &str,
598    extracted_commands: Option<&[String]>,
599) -> Option<String> {
600    if tool_name != terminal_tool_name {
601        return None;
602    }
603
604    let rules = &*HARDCODED_SECURITY_RULES;
605    let terminal_patterns = &rules.terminal_deny;
606
607    if matches_hardcoded_patterns(input, terminal_patterns) {
608        return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
609    }
610
611    if let Some(commands) = extracted_commands {
612        for command in commands {
613            if matches_hardcoded_patterns(command, terminal_patterns) {
614                return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
615            }
616        }
617    }
618
619    None
620}
621
622fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
623    for pattern in patterns {
624        if pattern.is_match(command) {
625            return true;
626        }
627    }
628
629    for expanded in expand_rm_to_single_path_commands(command) {
630        for pattern in patterns {
631            if pattern.is_match(&expanded) {
632                return true;
633            }
634        }
635    }
636
637    false
638}
639
640fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
641    let trimmed = command.trim();
642
643    let first_token = trimmed.split_whitespace().next();
644    if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
645        return vec![];
646    }
647
648    let parts: Vec<&str> = trimmed.split_whitespace().collect();
649    let mut flags = Vec::new();
650    let mut paths = Vec::new();
651    let mut past_double_dash = false;
652
653    for part in parts.iter().skip(1) {
654        if !past_double_dash && *part == "--" {
655            past_double_dash = true;
656            flags.push(*part);
657            continue;
658        }
659        if !past_double_dash && part.starts_with('-') {
660            flags.push(*part);
661        } else {
662            paths.push(*part);
663        }
664    }
665
666    let flags_str = if flags.is_empty() {
667        String::new()
668    } else {
669        format!("{} ", flags.join(" "))
670    };
671
672    let mut results = Vec::new();
673    for path in &paths {
674        if path.starts_with('$') {
675            let home_prefix = if path.starts_with("${HOME}") {
676                Some("${HOME}")
677            } else if path.starts_with("$HOME") {
678                Some("$HOME")
679            } else {
680                None
681            };
682
683            if let Some(prefix) = home_prefix {
684                let suffix = &path[prefix.len()..];
685                if suffix.is_empty() {
686                    results.push(format!("rm {flags_str}{path}"));
687                } else if suffix.starts_with('/') {
688                    let normalized_suffix = normalize_path(suffix);
689                    let reconstructed = if normalized_suffix == "/" {
690                        prefix.to_string()
691                    } else {
692                        format!("{prefix}{normalized_suffix}")
693                    };
694                    results.push(format!("rm {flags_str}{reconstructed}"));
695                } else {
696                    results.push(format!("rm {flags_str}{path}"));
697                }
698            } else {
699                results.push(format!("rm {flags_str}{path}"));
700            }
701            continue;
702        }
703
704        let mut normalized = normalize_path(path);
705        if normalized.is_empty() && !Path::new(path).has_root() {
706            normalized = ".".to_string();
707        }
708
709        results.push(format!("rm {flags_str}{normalized}"));
710    }
711
712    results
713}
714
715pub fn normalize_path(raw: &str) -> String {
716    let is_absolute = Path::new(raw).has_root();
717    let mut components: Vec<&str> = Vec::new();
718    for component in Path::new(raw).components() {
719        match component {
720            Component::CurDir => {}
721            Component::ParentDir => {
722                if components.last() == Some(&"..") {
723                    components.push("..");
724                } else if !components.is_empty() {
725                    components.pop();
726                } else if !is_absolute {
727                    components.push("..");
728                }
729            }
730            Component::Normal(segment) => {
731                if let Some(s) = segment.to_str() {
732                    components.push(s);
733                }
734            }
735            Component::RootDir | Component::Prefix(_) => {}
736        }
737    }
738    let joined = components.join("/");
739    if is_absolute {
740        format!("/{joined}")
741    } else {
742        joined
743    }
744}
745
746impl Settings for AgentSettings {
747    fn from_settings(content: &settings::SettingsContent) -> Self {
748        let agent = content.agent.clone().unwrap();
749        Self {
750            enabled: agent.enabled.unwrap(),
751            button: agent.button.unwrap(),
752            dock: agent.dock.unwrap(),
753            sidebar_side: agent.sidebar_side.unwrap(),
754            default_width: px(agent.default_width.unwrap()),
755            default_height: px(agent.default_height.unwrap()),
756            max_content_width: if agent.limit_content_width.unwrap() {
757                Some(px(agent.max_content_width.unwrap()))
758            } else {
759                None
760            },
761            flexible: agent.flexible.unwrap(),
762            default_model: Some(agent.default_model.unwrap()),
763            subagent_model: agent.subagent_model,
764            inline_assistant_model: agent.inline_assistant_model,
765            inline_assistant_use_streaming_tools: agent
766                .inline_assistant_use_streaming_tools
767                .unwrap_or(true),
768            commit_message_include_project_rules: agent
769                .commit_message_include_project_rules
770                .unwrap(),
771            commit_message_model: agent.commit_message_model,
772            commit_message_instructions: agent.commit_message_instructions,
773            thread_summary_model: agent.thread_summary_model,
774            compaction_model: agent.compaction_model,
775            inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
776            favorite_models: agent.favorite_models,
777            default_profile: AgentProfileId(agent.default_profile.unwrap()),
778            profiles: agent
779                .profiles
780                .unwrap()
781                .into_iter()
782                .map(|(key, val)| (AgentProfileId(key), val.into()))
783                .collect(),
784
785            notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
786            play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap_or_default(),
787            single_file_review: agent.single_file_review.unwrap(),
788            model_parameters: agent.model_parameters,
789            auto_compact: {
790                let auto_compact = agent.auto_compact.unwrap();
791                let threshold = parse_auto_compact_threshold(&auto_compact.threshold.unwrap().0)
792                    .log_err()
793                    .unwrap_or(AutoCompactThreshold::DEFAULT);
794                AutoCompactSettings {
795                    enabled: auto_compact.enabled.unwrap(),
796                    threshold,
797                }
798            },
799            enable_feedback: agent.enable_feedback.unwrap(),
800            expand_edit_card: agent.expand_edit_card.unwrap(),
801            expand_terminal_card: agent.expand_terminal_card.unwrap(),
802            terminal_init_command: agent
803                .terminal_init_command
804                .filter(|command| !command.trim().is_empty()),
805            thinking_display: agent.thinking_display.unwrap(),
806            cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(),
807            use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
808            message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
809            show_turn_stats: agent.show_turn_stats.unwrap(),
810            show_merge_conflict_indicator: agent.show_merge_conflict_indicator.unwrap(),
811            tool_permissions: compile_tool_permissions(agent.tool_permissions),
812            sandbox_permissions: compile_sandbox_permissions(agent.sandbox_permissions),
813        }
814    }
815}
816
817fn compile_sandbox_permissions(
818    content: Option<settings::SandboxPermissionsContent>,
819) -> SandboxPermissions {
820    let Some(content) = content else {
821        return SandboxPermissions::default();
822    };
823
824    let mut write_paths = Vec::new();
825    for path in content.write_paths.map(|paths| paths.0).unwrap_or_default() {
826        // Normalize away `..`/`.` before storing, since coverage checks are
827        // purely lexical; drop paths that escape the filesystem root.
828        if let Ok(normalized) = util::paths::normalize_lexically(&path) {
829            util::paths::insert_subtree(&mut write_paths, normalized);
830        }
831    }
832
833    let network_hosts = content
834        .network_hosts
835        .map(|hosts| hosts.0)
836        .unwrap_or_default();
837
838    SandboxPermissions {
839        allow_all_hosts: content.allow_all_hosts.unwrap_or(false),
840        network_hosts,
841        allow_fs_write_all: content.allow_fs_write_all.unwrap_or(false),
842        allow_unsandboxed: content.allow_unsandboxed.unwrap_or(false),
843        write_paths,
844        warn_confusable_unicode: content.warn_confusable_unicode.unwrap_or(true),
845    }
846}
847
848fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
849    let Some(content) = content else {
850        return ToolPermissions::default();
851    };
852
853    let tools = content
854        .tools
855        .into_iter()
856        .map(|(tool_name, rules_content)| {
857            let mut invalid_patterns = Vec::new();
858
859            let (always_allow, allow_errors) = compile_regex_rules(
860                rules_content.always_allow.map(|v| v.0).unwrap_or_default(),
861                "always_allow",
862            );
863            invalid_patterns.extend(allow_errors);
864
865            let (always_deny, deny_errors) = compile_regex_rules(
866                rules_content.always_deny.map(|v| v.0).unwrap_or_default(),
867                "always_deny",
868            );
869            invalid_patterns.extend(deny_errors);
870
871            let (always_confirm, confirm_errors) = compile_regex_rules(
872                rules_content
873                    .always_confirm
874                    .map(|v| v.0)
875                    .unwrap_or_default(),
876                "always_confirm",
877            );
878            invalid_patterns.extend(confirm_errors);
879
880            // Log invalid patterns for debugging. Users will see an error when they
881            // attempt to use a tool with invalid patterns in their settings.
882            for invalid in &invalid_patterns {
883                log::error!(
884                    "Invalid regex pattern in tool_permissions for '{}' tool ({}): '{}' - {}",
885                    tool_name,
886                    invalid.rule_type,
887                    invalid.pattern,
888                    invalid.error,
889                );
890            }
891
892            let rules = ToolRules {
893                // Preserve tool-specific default; None means fall back to global default at decision time
894                default: rules_content.default,
895                always_allow,
896                always_deny,
897                always_confirm,
898                invalid_patterns,
899            };
900            (tool_name, rules)
901        })
902        .collect();
903
904    ToolPermissions {
905        default: content.default.unwrap_or_default(),
906        tools,
907    }
908}
909
910fn compile_regex_rules(
911    rules: Vec<settings::ToolRegexRule>,
912    rule_type: &str,
913) -> (Vec<CompiledRegex>, Vec<InvalidRegexPattern>) {
914    let mut compiled = Vec::new();
915    let mut errors = Vec::new();
916
917    for rule in rules {
918        if rule.pattern.is_empty() {
919            errors.push(InvalidRegexPattern {
920                pattern: rule.pattern,
921                rule_type: rule_type.to_string(),
922                error: "empty regex patterns are not allowed".to_string(),
923            });
924            continue;
925        }
926        let case_sensitive = rule.case_sensitive.unwrap_or(false);
927        match CompiledRegex::try_new(&rule.pattern, case_sensitive) {
928            Ok(regex) => compiled.push(regex),
929            Err(error) => {
930                errors.push(InvalidRegexPattern {
931                    pattern: rule.pattern,
932                    rule_type: rule_type.to_string(),
933                    error: error.to_string(),
934                });
935            }
936        }
937    }
938
939    (compiled, errors)
940}
941
942#[cfg(test)]
943mod tests {
944    use super::*;
945    use gpui::{TestAppContext, UpdateGlobal};
946    use serde_json::json;
947    use settings::ToolPermissionMode;
948    use settings::ToolPermissionsContent;
949
950    #[test]
951    fn test_parse_auto_compact_threshold() {
952        use AutoCompactThreshold::*;
953
954        assert_eq!(
955            parse_auto_compact_threshold("90%").unwrap(),
956            Percentage(0.9)
957        );
958        assert_eq!(AutoCompactThreshold::DEFAULT, Percentage(0.9));
959        assert_eq!(
960            parse_auto_compact_threshold("  92.5% ").unwrap(),
961            Percentage(0.925)
962        );
963        assert_eq!(
964            parse_auto_compact_threshold("95.5%").unwrap(),
965            Percentage(0.955)
966        );
967        assert_eq!(
968            parse_auto_compact_threshold("100%").unwrap(),
969            Percentage(1.0)
970        );
971        // Token counts must be integers; a non-integer token value is invalid.
972        assert!(parse_auto_compact_threshold("100.5").is_err());
973        assert_eq!(
974            parse_auto_compact_threshold("100000").unwrap(),
975            TokensUsed(100_000)
976        );
977        assert_eq!(
978            parse_auto_compact_threshold("-20000").unwrap(),
979            TokensRemaining(20_000)
980        );
981
982        assert_eq!(Percentage(0.9).to_string(), "90%");
983        assert_eq!(Percentage(0.925).to_string(), "92.5%");
984        assert_eq!(TokensUsed(100_000).to_string(), "100000");
985        assert_eq!(TokensRemaining(20_000).to_string(), "-20000");
986
987        // 0 is invalid in every form.
988        assert!(parse_auto_compact_threshold("0").is_err());
989        assert!(parse_auto_compact_threshold("0%").is_err());
990        // Out-of-range percentages and bare decimals are invalid.
991        assert!(parse_auto_compact_threshold("150%").is_err());
992        assert!(parse_auto_compact_threshold("0.8").is_err());
993        assert!(parse_auto_compact_threshold("eighty percent").is_err());
994    }
995
996    #[test]
997    fn test_compiled_regex_case_insensitive() {
998        let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
999        assert!(regex.is_match("rm -rf /"));
1000        assert!(regex.is_match("RM -RF /"));
1001        assert!(regex.is_match("Rm -Rf /"));
1002    }
1003
1004    #[test]
1005    fn test_compiled_regex_case_sensitive() {
1006        let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
1007        assert!(regex.is_match("DROP TABLE users"));
1008        assert!(!regex.is_match("drop table users"));
1009    }
1010
1011    #[test]
1012    fn test_invalid_regex_returns_none() {
1013        let result = CompiledRegex::new("[invalid(regex", false);
1014        assert!(result.is_none());
1015    }
1016
1017    #[gpui::test]
1018    fn test_terminal_init_command_filters_empty_without_trimming(cx: &mut gpui::App) {
1019        let store = SettingsStore::test(cx);
1020        cx.set_global(store);
1021        project::DisableAiSettings::register(cx);
1022        AgentSettings::register(cx);
1023
1024        SettingsStore::update_global(cx, |store, cx| {
1025            let new_text = store
1026                .new_text_for_update("{}".to_string(), |settings| {
1027                    settings.agent.get_or_insert_default().terminal_init_command =
1028                        Some(" claude --resume ".to_string());
1029                })
1030                .unwrap();
1031            assert!(
1032                new_text.contains(r#""terminal_init_command": " claude --resume ""#),
1033                "updated settings JSON should include terminal_init_command, got {new_text}"
1034            );
1035            store.set_user_settings(&new_text, cx).unwrap();
1036        });
1037        assert_eq!(
1038            AgentSettings::get_global(cx)
1039                .terminal_init_command
1040                .as_deref(),
1041            Some(" claude --resume ")
1042        );
1043
1044        SettingsStore::update_global(cx, |store, cx| {
1045            store
1046                .set_user_settings(r#"{ "agent": { "terminal_init_command": "   " } }"#, cx)
1047                .unwrap();
1048        });
1049        assert!(
1050            AgentSettings::get_global(cx)
1051                .terminal_init_command
1052                .is_none()
1053        );
1054
1055        SettingsStore::update_global(cx, |store, cx| {
1056            store
1057                .set_user_settings(r#"{ "agent": { "terminal_init_command": null } }"#, cx)
1058                .unwrap();
1059        });
1060        assert!(
1061            AgentSettings::get_global(cx)
1062                .terminal_init_command
1063                .is_none()
1064        );
1065    }
1066
1067    #[test]
1068    fn test_tool_permissions_parsing() {
1069        let json = json!({
1070            "tools": {
1071                "terminal": {
1072                    "default": "allow",
1073                    "always_deny": [
1074                        { "pattern": "rm\\s+-rf" }
1075                    ],
1076                    "always_allow": [
1077                        { "pattern": "^git\\s" }
1078                    ]
1079                }
1080            }
1081        });
1082
1083        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1084        let permissions = compile_tool_permissions(Some(content));
1085
1086        let terminal_rules = permissions.tools.get("terminal").unwrap();
1087        assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
1088        assert_eq!(terminal_rules.always_deny.len(), 1);
1089        assert_eq!(terminal_rules.always_allow.len(), 1);
1090        assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
1091        assert!(terminal_rules.always_allow[0].is_match("git status"));
1092    }
1093
1094    #[test]
1095    fn test_tool_rules_default() {
1096        let json = json!({
1097            "tools": {
1098                "edit_file": {
1099                    "default": "deny"
1100                }
1101            }
1102        });
1103
1104        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1105        let permissions = compile_tool_permissions(Some(content));
1106
1107        let rules = permissions.tools.get("edit_file").unwrap();
1108        assert_eq!(rules.default, Some(ToolPermissionMode::Deny));
1109    }
1110
1111    #[test]
1112    fn test_tool_permissions_empty() {
1113        let permissions = compile_tool_permissions(None);
1114        assert!(permissions.tools.is_empty());
1115        assert_eq!(permissions.default, ToolPermissionMode::Confirm);
1116    }
1117
1118    #[test]
1119    fn test_sandbox_permissions_empty() {
1120        let permissions = compile_sandbox_permissions(None);
1121        assert_eq!(permissions, SandboxPermissions::default());
1122        // The confusable-Unicode warning is a safety net, so it's on by default.
1123        assert!(permissions.warn_confusable_unicode);
1124    }
1125
1126    #[test]
1127    fn test_sandbox_permissions_warn_confusable_unicode_can_be_disabled() {
1128        let content: settings::SandboxPermissionsContent =
1129            serde_json::from_value(json!({ "warn_confusable_unicode": false })).unwrap();
1130        let permissions = compile_sandbox_permissions(Some(content));
1131        assert!(!permissions.warn_confusable_unicode);
1132
1133        // Omitting the key keeps the warning enabled.
1134        let content: settings::SandboxPermissionsContent =
1135            serde_json::from_value(json!({})).unwrap();
1136        let permissions = compile_sandbox_permissions(Some(content));
1137        assert!(permissions.warn_confusable_unicode);
1138    }
1139
1140    #[test]
1141    fn test_sandbox_permissions_parsing_and_pruning() {
1142        let json = json!({
1143            "allow_all_hosts": true,
1144            "network_hosts": ["github.com", "*.npmjs.org"],
1145            "allow_unsandboxed": true,
1146            "write_paths": [
1147                "/tmp/build/cache",
1148                "/tmp/build",
1149                "/var/log"
1150            ]
1151        });
1152
1153        let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap();
1154        let permissions = compile_sandbox_permissions(Some(content));
1155
1156        assert!(permissions.allow_all_hosts);
1157        assert_eq!(
1158            permissions.network_hosts,
1159            vec!["github.com".to_string(), "*.npmjs.org".to_string()]
1160        );
1161        assert!(!permissions.allow_fs_write_all);
1162        assert!(permissions.allow_unsandboxed);
1163        assert_eq!(
1164            permissions.write_paths,
1165            vec![PathBuf::from("/tmp/build"), PathBuf::from("/var/log")]
1166        );
1167    }
1168
1169    #[test]
1170    fn test_sandbox_permissions_normalizes_and_prunes_parent_traversal() {
1171        let json = json!({
1172            "write_paths": [
1173                "/tmp/build/../build/cache",
1174                "/tmp/build",
1175            ]
1176        });
1177
1178        let content: settings::SandboxPermissionsContent = serde_json::from_value(json).unwrap();
1179        let permissions = compile_sandbox_permissions(Some(content));
1180
1181        // `/tmp/build/../build/cache` normalizes to `/tmp/build/cache`, which is
1182        // then pruned as a redundant child of `/tmp/build`.
1183        assert_eq!(permissions.write_paths, vec![PathBuf::from("/tmp/build")]);
1184    }
1185
1186    #[test]
1187    fn test_tool_rules_default_returns_confirm() {
1188        let default_rules = ToolRules::default();
1189        assert_eq!(default_rules.default, None);
1190        assert!(default_rules.always_allow.is_empty());
1191        assert!(default_rules.always_deny.is_empty());
1192        assert!(default_rules.always_confirm.is_empty());
1193    }
1194
1195    #[test]
1196    fn test_tool_permissions_with_multiple_tools() {
1197        let json = json!({
1198            "tools": {
1199                "terminal": {
1200                    "default": "allow",
1201                    "always_deny": [{ "pattern": "rm\\s+-rf" }]
1202                },
1203                "edit_file": {
1204                    "default": "confirm",
1205                    "always_deny": [{ "pattern": "\\.env$" }]
1206                },
1207                "delete_path": {
1208                    "default": "deny"
1209                }
1210            }
1211        });
1212
1213        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1214        let permissions = compile_tool_permissions(Some(content));
1215
1216        assert_eq!(permissions.tools.len(), 3);
1217
1218        let terminal = permissions.tools.get("terminal").unwrap();
1219        assert_eq!(terminal.default, Some(ToolPermissionMode::Allow));
1220        assert_eq!(terminal.always_deny.len(), 1);
1221
1222        let edit_file = permissions.tools.get("edit_file").unwrap();
1223        assert_eq!(edit_file.default, Some(ToolPermissionMode::Confirm));
1224        assert!(edit_file.always_deny[0].is_match("secrets.env"));
1225
1226        let delete_path = permissions.tools.get("delete_path").unwrap();
1227        assert_eq!(delete_path.default, Some(ToolPermissionMode::Deny));
1228    }
1229
1230    #[test]
1231    fn test_tool_permissions_with_all_rule_types() {
1232        let json = json!({
1233            "tools": {
1234                "terminal": {
1235                    "always_deny": [{ "pattern": "rm\\s+-rf" }],
1236                    "always_confirm": [{ "pattern": "sudo\\s" }],
1237                    "always_allow": [{ "pattern": "^git\\s+status" }]
1238                }
1239            }
1240        });
1241
1242        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1243        let permissions = compile_tool_permissions(Some(content));
1244
1245        let terminal = permissions.tools.get("terminal").unwrap();
1246        assert_eq!(terminal.always_deny.len(), 1);
1247        assert_eq!(terminal.always_confirm.len(), 1);
1248        assert_eq!(terminal.always_allow.len(), 1);
1249
1250        assert!(terminal.always_deny[0].is_match("rm -rf /"));
1251        assert!(terminal.always_confirm[0].is_match("sudo apt install"));
1252        assert!(terminal.always_allow[0].is_match("git status"));
1253    }
1254
1255    #[test]
1256    fn test_invalid_regex_is_tracked_and_valid_ones_still_compile() {
1257        let json = json!({
1258            "tools": {
1259                "terminal": {
1260                    "always_deny": [
1261                        { "pattern": "[invalid(regex" },
1262                        { "pattern": "valid_pattern" }
1263                    ],
1264                    "always_allow": [
1265                        { "pattern": "[another_bad" }
1266                    ]
1267                }
1268            }
1269        });
1270
1271        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1272        let permissions = compile_tool_permissions(Some(content));
1273
1274        let terminal = permissions.tools.get("terminal").unwrap();
1275
1276        // Valid patterns should still be compiled
1277        assert_eq!(terminal.always_deny.len(), 1);
1278        assert!(terminal.always_deny[0].is_match("valid_pattern"));
1279
1280        // Invalid patterns should be tracked (order depends on processing order)
1281        assert_eq!(terminal.invalid_patterns.len(), 2);
1282
1283        let deny_invalid = terminal
1284            .invalid_patterns
1285            .iter()
1286            .find(|p| p.rule_type == "always_deny")
1287            .expect("should have invalid pattern from always_deny");
1288        assert_eq!(deny_invalid.pattern, "[invalid(regex");
1289        assert!(!deny_invalid.error.is_empty());
1290
1291        let allow_invalid = terminal
1292            .invalid_patterns
1293            .iter()
1294            .find(|p| p.rule_type == "always_allow")
1295            .expect("should have invalid pattern from always_allow");
1296        assert_eq!(allow_invalid.pattern, "[another_bad");
1297
1298        // ToolPermissions helper methods should work
1299        assert!(permissions.has_invalid_patterns());
1300        assert_eq!(permissions.invalid_patterns().len(), 2);
1301    }
1302
1303    #[test]
1304    fn test_deny_takes_precedence_over_allow_and_confirm() {
1305        let json = json!({
1306            "tools": {
1307                "terminal": {
1308                    "default": "allow",
1309                    "always_deny": [{ "pattern": "dangerous" }],
1310                    "always_confirm": [{ "pattern": "dangerous" }],
1311                    "always_allow": [{ "pattern": "dangerous" }]
1312                }
1313            }
1314        });
1315
1316        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1317        let permissions = compile_tool_permissions(Some(content));
1318        let terminal = permissions.tools.get("terminal").unwrap();
1319
1320        assert!(
1321            terminal.always_deny[0].is_match("run dangerous command"),
1322            "Deny rule should match"
1323        );
1324        assert!(
1325            terminal.always_allow[0].is_match("run dangerous command"),
1326            "Allow rule should also match (but deny takes precedence at evaluation time)"
1327        );
1328        assert!(
1329            terminal.always_confirm[0].is_match("run dangerous command"),
1330            "Confirm rule should also match (but deny takes precedence at evaluation time)"
1331        );
1332    }
1333
1334    #[test]
1335    fn test_confirm_takes_precedence_over_allow() {
1336        let json = json!({
1337            "tools": {
1338                "terminal": {
1339                    "default": "allow",
1340                    "always_confirm": [{ "pattern": "risky" }],
1341                    "always_allow": [{ "pattern": "risky" }]
1342                }
1343            }
1344        });
1345
1346        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1347        let permissions = compile_tool_permissions(Some(content));
1348        let terminal = permissions.tools.get("terminal").unwrap();
1349
1350        assert!(
1351            terminal.always_confirm[0].is_match("do risky thing"),
1352            "Confirm rule should match"
1353        );
1354        assert!(
1355            terminal.always_allow[0].is_match("do risky thing"),
1356            "Allow rule should also match (but confirm takes precedence at evaluation time)"
1357        );
1358    }
1359
1360    #[test]
1361    fn test_regex_matches_anywhere_in_string_not_just_anchored() {
1362        let json = json!({
1363            "tools": {
1364                "terminal": {
1365                    "always_deny": [
1366                        { "pattern": "rm\\s+-rf" },
1367                        { "pattern": "/etc/passwd" }
1368                    ]
1369                }
1370            }
1371        });
1372
1373        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1374        let permissions = compile_tool_permissions(Some(content));
1375        let terminal = permissions.tools.get("terminal").unwrap();
1376
1377        assert!(
1378            terminal.always_deny[0].is_match("echo hello && rm -rf /"),
1379            "Should match rm -rf in the middle of a command chain"
1380        );
1381        assert!(
1382            terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
1383            "Should match rm -rf after semicolon"
1384        );
1385        assert!(
1386            terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
1387            "Should match /etc/passwd in a pipeline"
1388        );
1389        assert!(
1390            terminal.always_deny[1].is_match("vim /etc/passwd"),
1391            "Should match /etc/passwd as argument"
1392        );
1393    }
1394
1395    #[test]
1396    fn test_fork_bomb_pattern_matches() {
1397        let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
1398        assert!(
1399            fork_bomb_regex.is_match(":(){ :|:& };:"),
1400            "Should match the classic fork bomb"
1401        );
1402        assert!(
1403            fork_bomb_regex.is_match(":(){ :|:&};:"),
1404            "Should match fork bomb without spaces"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_compiled_regex_stores_case_sensitivity() {
1410        let case_sensitive = CompiledRegex::new("test", true).unwrap();
1411        let case_insensitive = CompiledRegex::new("test", false).unwrap();
1412
1413        assert!(case_sensitive.case_sensitive);
1414        assert!(!case_insensitive.case_sensitive);
1415    }
1416
1417    #[test]
1418    fn test_invalid_regex_is_skipped_not_fail() {
1419        let json = json!({
1420            "tools": {
1421                "terminal": {
1422                    "always_deny": [
1423                        { "pattern": "[invalid(regex" },
1424                        { "pattern": "valid_pattern" }
1425                    ]
1426                }
1427            }
1428        });
1429
1430        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1431        let permissions = compile_tool_permissions(Some(content));
1432
1433        let terminal = permissions.tools.get("terminal").unwrap();
1434        assert_eq!(terminal.always_deny.len(), 1);
1435        assert!(terminal.always_deny[0].is_match("valid_pattern"));
1436    }
1437
1438    #[test]
1439    fn test_unconfigured_tool_not_in_permissions() {
1440        let json = json!({
1441            "tools": {
1442                "terminal": {
1443                    "default": "allow"
1444                }
1445            }
1446        });
1447
1448        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1449        let permissions = compile_tool_permissions(Some(content));
1450
1451        assert!(permissions.tools.contains_key("terminal"));
1452        assert!(!permissions.tools.contains_key("edit_file"));
1453        assert!(!permissions.tools.contains_key("fetch"));
1454    }
1455
1456    #[test]
1457    fn test_always_allow_pattern_only_matches_specified_commands() {
1458        // Reproduces user-reported bug: when always_allow has pattern "^echo\s",
1459        // only "echo hello" should be allowed, not "git status".
1460        //
1461        // User config:
1462        //   always_allow_tool_actions: false
1463        //   tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
1464        let json = json!({
1465            "tools": {
1466                "terminal": {
1467                    "always_allow": [
1468                        { "pattern": "^echo\\s" }
1469                    ]
1470                }
1471            }
1472        });
1473
1474        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1475        let permissions = compile_tool_permissions(Some(content));
1476
1477        let terminal = permissions.tools.get("terminal").unwrap();
1478
1479        // Verify the pattern was compiled
1480        assert_eq!(
1481            terminal.always_allow.len(),
1482            1,
1483            "Should have one always_allow pattern"
1484        );
1485
1486        // Verify the pattern matches "echo hello"
1487        assert!(
1488            terminal.always_allow[0].is_match("echo hello"),
1489            "Pattern ^echo\\s should match 'echo hello'"
1490        );
1491
1492        // Verify the pattern does NOT match "git status"
1493        assert!(
1494            !terminal.always_allow[0].is_match("git status"),
1495            "Pattern ^echo\\s should NOT match 'git status'"
1496        );
1497
1498        // Verify the pattern does NOT match "echoHello" (no space)
1499        assert!(
1500            !terminal.always_allow[0].is_match("echoHello"),
1501            "Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
1502        );
1503
1504        assert_eq!(
1505            terminal.default, None,
1506            "default should be None when not specified"
1507        );
1508    }
1509
1510    #[test]
1511    fn test_empty_regex_pattern_is_invalid() {
1512        let json = json!({
1513            "tools": {
1514                "terminal": {
1515                    "always_allow": [
1516                        { "pattern": "" }
1517                    ],
1518                    "always_deny": [
1519                        { "case_sensitive": true }
1520                    ],
1521                    "always_confirm": [
1522                        { "pattern": "" },
1523                        { "pattern": "valid_pattern" }
1524                    ]
1525                }
1526            }
1527        });
1528
1529        let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
1530        let permissions = compile_tool_permissions(Some(content));
1531
1532        let terminal = permissions.tools.get("terminal").unwrap();
1533
1534        assert_eq!(terminal.always_allow.len(), 0);
1535        assert_eq!(terminal.always_deny.len(), 0);
1536        assert_eq!(terminal.always_confirm.len(), 1);
1537        assert!(terminal.always_confirm[0].is_match("valid_pattern"));
1538
1539        assert_eq!(terminal.invalid_patterns.len(), 3);
1540        for invalid in &terminal.invalid_patterns {
1541            assert_eq!(invalid.pattern, "");
1542            assert!(invalid.error.contains("empty"));
1543        }
1544    }
1545
1546    #[test]
1547    fn test_default_json_tool_permissions_parse() {
1548        let default_json = include_str!("../../../assets/settings/default.json");
1549        let value: serde_json_lenient::Value = serde_json_lenient::from_str(default_json).unwrap();
1550        let agent = value
1551            .get("agent")
1552            .expect("default.json should have 'agent' key");
1553        let tool_permissions_value = agent
1554            .get("tool_permissions")
1555            .expect("agent should have 'tool_permissions' key");
1556
1557        let content: ToolPermissionsContent =
1558            serde_json_lenient::from_value(tool_permissions_value.clone()).unwrap();
1559        let permissions = compile_tool_permissions(Some(content));
1560
1561        // OMEGA-DELTA-0002: Omega ships "allow" here; upstream Zed ships
1562        // "confirm". Omega runs agents unattended, where a confirmation prompt
1563        // is a hang rather than a safeguard, so the shipped default does not
1564        // ask before every tool action. Do not "fix" this back to Confirm to
1565        // match upstream — the divergence is deliberate and is independently
1566        // enforced by omega_deltas::agent_tool_permissions_default_to_allow.
1567        // Parsing of the "confirm" mode itself stays covered by
1568        // test_tool_permissions_explicit_global_default.
1569        assert_eq!(
1570            permissions.default,
1571            ToolPermissionMode::Allow,
1572            "OMEGA-DELTA-0002: the shipped agent.tool_permissions.default must \
1573             be \"allow\". If a rebase restored upstream's \"confirm\", fix \
1574             assets/settings/default.json rather than this assertion."
1575        );
1576
1577        assert!(
1578            permissions.tools.is_empty(),
1579            "default.json should not have any active tool-specific rules, found: {:?}",
1580            permissions.tools.keys().collect::<Vec<_>>()
1581        );
1582    }
1583
1584    #[test]
1585    fn test_tool_permissions_explicit_global_default() {
1586        let json_allow = json!({
1587            "default": "allow"
1588        });
1589        let content: ToolPermissionsContent = serde_json::from_value(json_allow).unwrap();
1590        let permissions = compile_tool_permissions(Some(content));
1591        assert_eq!(permissions.default, ToolPermissionMode::Allow);
1592
1593        let json_deny = json!({
1594            "default": "deny"
1595        });
1596        let content: ToolPermissionsContent = serde_json::from_value(json_deny).unwrap();
1597        let permissions = compile_tool_permissions(Some(content));
1598        assert_eq!(permissions.default, ToolPermissionMode::Deny);
1599
1600        // "confirm" used to be covered only by reading the shipped
1601        // default.json, which under OMEGA-DELTA-0002 now says "allow". The
1602        // parser still has to understand "confirm", because that is how an
1603        // operator draws the line back, so it gets an explicit fixture here.
1604        let json_confirm = json!({
1605            "default": "confirm"
1606        });
1607        let content: ToolPermissionsContent = serde_json::from_value(json_confirm).unwrap();
1608        let permissions = compile_tool_permissions(Some(content));
1609        assert_eq!(permissions.default, ToolPermissionMode::Confirm);
1610    }
1611
1612    #[gpui::test]
1613    fn test_get_layout(cx: &mut gpui::App) {
1614        let store = SettingsStore::test(cx);
1615        cx.set_global(store);
1616        project::DisableAiSettings::register(cx);
1617        AgentSettings::register(cx);
1618
1619        // Should be Agent with an empty user layout (user hasn't customized).
1620        let layout = AgentSettings::get_layout(cx);
1621        let WindowLayout::Agent(Some(user_layout)) = layout else {
1622            panic!("expected Agent(Some), got {:?}", layout);
1623        };
1624        assert_eq!(user_layout, PanelLayout::default());
1625
1626        // User explicitly sets agent dock to left (matching the default).
1627        // The merged result is still agent, but the user layout captures
1628        // only what the user wrote.
1629        SettingsStore::update_global(cx, |store, cx| {
1630            store
1631                .set_user_settings(r#"{ "agent": { "dock": "left" } }"#, cx)
1632                .unwrap();
1633        });
1634
1635        let layout = AgentSettings::get_layout(cx);
1636        let WindowLayout::Agent(Some(user_layout)) = layout else {
1637            panic!("expected Agent(Some), got {:?}", layout);
1638        };
1639        assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1640        assert_eq!(user_layout.project_panel_dock, None);
1641        assert_eq!(user_layout.outline_panel_dock, None);
1642        assert_eq!(user_layout.collaboration_panel_dock, None);
1643        assert_eq!(user_layout.git_panel_dock, None);
1644
1645        // User sets a combination that doesn't match either preset:
1646        // agent on the left but project panel also on the left.
1647        SettingsStore::update_global(cx, |store, cx| {
1648            store
1649                .set_user_settings(
1650                    r#"{
1651                        "agent": { "dock": "left" },
1652                        "project_panel": { "dock": "left" }
1653                    }"#,
1654                    cx,
1655                )
1656                .unwrap();
1657        });
1658
1659        let layout = AgentSettings::get_layout(cx);
1660        let WindowLayout::Custom(user_layout) = layout else {
1661            panic!("expected Custom, got {:?}", layout);
1662        };
1663        assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1664        assert_eq!(user_layout.project_panel_dock, Some(DockSide::Left));
1665    }
1666
1667    #[gpui::test]
1668    fn test_set_layout_round_trip(cx: &mut gpui::App) {
1669        let store = SettingsStore::test(cx);
1670        cx.set_global(store);
1671        project::DisableAiSettings::register(cx);
1672        AgentSettings::register(cx);
1673
1674        // User has a custom layout: agent on the right with project panel
1675        // also on the right. This doesn't match either preset.
1676        SettingsStore::update_global(cx, |store, cx| {
1677            store
1678                .set_user_settings(
1679                    r#"{
1680                        "agent": { "dock": "right" },
1681                        "project_panel": { "dock": "right" }
1682                    }"#,
1683                    cx,
1684                )
1685                .unwrap();
1686        });
1687
1688        let original = AgentSettings::get_layout(cx);
1689        let WindowLayout::Custom(ref original_user_layout) = original else {
1690            panic!("expected Custom, got {:?}", original);
1691        };
1692        assert_eq!(original_user_layout.agent_dock, Some(DockPosition::Right));
1693        assert_eq!(
1694            original_user_layout.project_panel_dock,
1695            Some(DockSide::Right)
1696        );
1697        assert_eq!(original_user_layout.outline_panel_dock, None);
1698
1699        // Switch to the agent layout. This overwrites the user settings.
1700        SettingsStore::update_global(cx, |store, cx| {
1701            store.update_user_settings(cx, |settings| {
1702                PanelLayout::AGENT.write_to(settings);
1703            });
1704        });
1705
1706        let layout = AgentSettings::get_layout(cx);
1707        assert!(matches!(layout, WindowLayout::Agent(_)));
1708
1709        // Restore the original custom layout.
1710        SettingsStore::update_global(cx, |store, cx| {
1711            store.update_user_settings(cx, |settings| {
1712                original_user_layout.write_to(settings);
1713            });
1714        });
1715
1716        // Should be back to the same custom layout.
1717        let restored = AgentSettings::get_layout(cx);
1718        let WindowLayout::Custom(restored_user_layout) = restored else {
1719            panic!("expected Custom, got {:?}", restored);
1720        };
1721        assert_eq!(restored_user_layout.agent_dock, Some(DockPosition::Right));
1722        assert_eq!(
1723            restored_user_layout.project_panel_dock,
1724            Some(DockSide::Right)
1725        );
1726        assert_eq!(restored_user_layout.outline_panel_dock, None);
1727    }
1728
1729    #[gpui::test]
1730    async fn test_set_layout_minimal_diff(cx: &mut TestAppContext) {
1731        let fs = fs::FakeFs::new(cx.background_executor.clone());
1732        fs.save(
1733            paths::settings_file().as_path(),
1734            &serde_json::json!({
1735                "agent": { "dock": "left" },
1736                "project_panel": { "dock": "left" }
1737            })
1738            .to_string()
1739            .into(),
1740            Default::default(),
1741        )
1742        .await
1743        .unwrap();
1744
1745        cx.update(|cx| {
1746            let store = SettingsStore::test(cx);
1747            cx.set_global(store);
1748            project::DisableAiSettings::register(cx);
1749            AgentSettings::register(cx);
1750
1751            // User has agent=left (matches preset) and project_panel=left (does not)
1752            SettingsStore::update_global(cx, |store, cx| {
1753                store
1754                    .set_user_settings(
1755                        r#"{
1756                            "agent": { "dock": "left" },
1757                            "project_panel": { "dock": "left" }
1758                        }"#,
1759                        cx,
1760                    )
1761                    .unwrap();
1762            });
1763
1764            let layout = AgentSettings::get_layout(cx);
1765            assert!(matches!(layout, WindowLayout::Custom(_)));
1766
1767            AgentSettings::set_layout(WindowLayout::agent(), fs.clone(), cx)
1768        })
1769        .await
1770        .ok();
1771
1772        cx.run_until_parked();
1773
1774        let written = fs.load(paths::settings_file().as_path()).await.unwrap();
1775        cx.update(|cx| {
1776            SettingsStore::update_global(cx, |store, cx| {
1777                store.set_user_settings(&written, cx).unwrap();
1778            });
1779
1780            // The user settings should still have agent=left (preserved)
1781            // and now project_panel=right (changed to match preset).
1782            let store = cx.global::<SettingsStore>();
1783            let user_layout = store
1784                .raw_user_settings()
1785                .map(|u| PanelLayout::read_from(u.content.as_ref()))
1786                .unwrap_or_default();
1787
1788            assert_eq!(user_layout.agent_dock, Some(DockPosition::Left));
1789            assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1790            // Other fields weren't in user settings and didn't need changing.
1791            assert_eq!(user_layout.outline_panel_dock, None);
1792
1793            // And the merged result should now match agent.
1794            let layout = AgentSettings::get_layout(cx);
1795            assert!(matches!(layout, WindowLayout::Agent(_)));
1796        });
1797    }
1798
1799    #[gpui::test]
1800    async fn test_backfill_editor_layout(cx: &mut TestAppContext) {
1801        let fs = fs::FakeFs::new(cx.background_executor.clone());
1802        // User has only customized project_panel to "right".
1803        fs.save(
1804            paths::settings_file().as_path(),
1805            &serde_json::json!({
1806                "project_panel": { "dock": "right" }
1807            })
1808            .to_string()
1809            .into(),
1810            Default::default(),
1811        )
1812        .await
1813        .unwrap();
1814
1815        cx.update(|cx| {
1816            let store = SettingsStore::test(cx);
1817            cx.set_global(store);
1818            project::DisableAiSettings::register(cx);
1819            AgentSettings::register(cx);
1820
1821            // Simulate pre-migration state: editor defaults (the old world).
1822            SettingsStore::update_global(cx, |store, cx| {
1823                store.update_default_settings(cx, |defaults| {
1824                    PanelLayout::EDITOR.write_to(defaults);
1825                });
1826            });
1827
1828            // User has only customized project_panel to "right".
1829            SettingsStore::update_global(cx, |store, cx| {
1830                store
1831                    .set_user_settings(r#"{ "project_panel": { "dock": "right" } }"#, cx)
1832                    .unwrap();
1833            });
1834
1835            // Run the one-time backfill while still on old defaults.
1836            AgentSettings::backfill_editor_layout(fs.clone(), cx);
1837        });
1838
1839        cx.run_until_parked();
1840
1841        // Read back the file and apply it.
1842        let written = fs.load(paths::settings_file().as_path()).await.unwrap();
1843        cx.update(|cx| {
1844            SettingsStore::update_global(cx, |store, cx| {
1845                store.set_user_settings(&written, cx).unwrap();
1846            });
1847
1848            // The user's project_panel=right should be preserved (they set it).
1849            // All other fields should now have the editor preset values
1850            // written into user settings.
1851            let store = cx.global::<SettingsStore>();
1852            let user_layout = store
1853                .raw_user_settings()
1854                .map(|u| PanelLayout::read_from(u.content.as_ref()))
1855                .unwrap_or_default();
1856
1857            assert_eq!(user_layout.agent_dock, Some(DockPosition::Right));
1858            assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1859            assert_eq!(user_layout.outline_panel_dock, Some(DockSide::Left));
1860            assert_eq!(
1861                user_layout.collaboration_panel_dock,
1862                Some(DockPosition::Left)
1863            );
1864            assert_eq!(user_layout.git_panel_dock, Some(DockPosition::Left));
1865
1866            // Even though defaults are now agent, the backfilled user settings
1867            // keep everything in the editor layout. The user's experience
1868            // hasn't changed.
1869            let layout = AgentSettings::get_layout(cx);
1870            let WindowLayout::Custom(user_layout) = layout else {
1871                panic!(
1872                    "expected Custom (editor values override agent defaults), got {:?}",
1873                    layout
1874                );
1875            };
1876            assert_eq!(user_layout.agent_dock, Some(DockPosition::Right));
1877            assert_eq!(user_layout.project_panel_dock, Some(DockSide::Right));
1878        });
1879    }
1880}
1881
Served at tenant.openagents/omega Member data and write actions are omitted.