Skip to repository content1881 lines · 69.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T01:51:10.345Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
agent_settings.rs
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