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