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