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