1mod agent_profile;
2
3use std::sync::Arc;
4
5use agent_client_protocol::ModelId;
6use collections::{HashSet, IndexMap};
7use gpui::{App, Pixels, px};
8use language_model::LanguageModel;
9use project::DisableAiSettings;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use settings::{
13 DefaultAgentView, DockPosition, DockSide, LanguageModelParameters, LanguageModelSelection,
14 NotifyWhenAgentWaiting, RegisterSetting, Settings, ToolPermissionMode,
15};
16
17pub use crate::agent_profile::*;
18
19pub const SUMMARIZE_THREAD_PROMPT: &str = include_str!("prompts/summarize_thread_prompt.txt");
20pub const SUMMARIZE_THREAD_DETAILED_PROMPT: &str =
21 include_str!("prompts/summarize_thread_detailed_prompt.txt");
22
23#[derive(Clone, Debug, RegisterSetting)]
24pub struct AgentSettings {
25 pub enabled: bool,
26 pub button: bool,
27 pub dock: DockPosition,
28 pub agents_panel_dock: DockSide,
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 pub always_allow_tool_actions: bool,
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}
55
56impl AgentSettings {
57 pub fn enabled(&self, cx: &App) -> bool {
58 self.enabled && !DisableAiSettings::get_global(cx).disable_ai
59 }
60
61 pub fn temperature_for_model(model: &Arc<dyn LanguageModel>, cx: &App) -> Option<f32> {
62 let settings = Self::get_global(cx);
63 for setting in settings.model_parameters.iter().rev() {
64 if let Some(provider) = &setting.provider
65 && provider.0 != model.provider_id().0
66 {
67 continue;
68 }
69 if let Some(setting_model) = &setting.model
70 && *setting_model != model.id().0
71 {
72 continue;
73 }
74 return setting.temperature;
75 }
76 return None;
77 }
78
79 pub fn set_inline_assistant_model(&mut self, provider: String, model: String) {
80 self.inline_assistant_model = Some(LanguageModelSelection {
81 provider: provider.into(),
82 model,
83 });
84 }
85
86 pub fn set_commit_message_model(&mut self, provider: String, model: String) {
87 self.commit_message_model = Some(LanguageModelSelection {
88 provider: provider.into(),
89 model,
90 });
91 }
92
93 pub fn set_thread_summary_model(&mut self, provider: String, model: String) {
94 self.thread_summary_model = Some(LanguageModelSelection {
95 provider: provider.into(),
96 model,
97 });
98 }
99
100 pub fn set_message_editor_max_lines(&self) -> usize {
101 self.message_editor_min_lines * 2
102 }
103
104 pub fn favorite_model_ids(&self) -> HashSet<ModelId> {
105 self.favorite_models
106 .iter()
107 .map(|sel| ModelId::new(format!("{}/{}", sel.provider.0, sel.model)))
108 .collect()
109 }
110}
111
112#[derive(Debug, PartialEq, Eq, Hash, Clone, Serialize, Deserialize, JsonSchema)]
113pub struct AgentProfileId(pub Arc<str>);
114
115impl AgentProfileId {
116 pub fn as_str(&self) -> &str {
117 &self.0
118 }
119}
120
121impl std::fmt::Display for AgentProfileId {
122 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123 write!(f, "{}", self.0)
124 }
125}
126
127impl Default for AgentProfileId {
128 fn default() -> Self {
129 Self("write".into())
130 }
131}
132
133#[derive(Clone, Debug, Default)]
134pub struct ToolPermissions {
135 pub tools: collections::HashMap<Arc<str>, ToolRules>,
136}
137
138impl ToolPermissions {
139 /// Returns all invalid regex patterns across all tools.
140 pub fn invalid_patterns(&self) -> Vec<&InvalidRegexPattern> {
141 self.tools
142 .values()
143 .flat_map(|rules| rules.invalid_patterns.iter())
144 .collect()
145 }
146
147 /// Returns true if any tool has invalid regex patterns.
148 pub fn has_invalid_patterns(&self) -> bool {
149 self.tools
150 .values()
151 .any(|rules| !rules.invalid_patterns.is_empty())
152 }
153}
154
155/// Represents a regex pattern that failed to compile.
156#[derive(Clone, Debug)]
157pub struct InvalidRegexPattern {
158 /// The pattern string that failed to compile.
159 pub pattern: String,
160 /// Which rule list this pattern was in (e.g., "always_deny", "always_allow", "always_confirm").
161 pub rule_type: String,
162 /// The error message from the regex compiler.
163 pub error: String,
164}
165
166#[derive(Clone, Debug)]
167pub struct ToolRules {
168 pub default_mode: ToolPermissionMode,
169 pub always_allow: Vec<CompiledRegex>,
170 pub always_deny: Vec<CompiledRegex>,
171 pub always_confirm: Vec<CompiledRegex>,
172 /// Patterns that failed to compile. If non-empty, tool calls should be blocked.
173 pub invalid_patterns: Vec<InvalidRegexPattern>,
174}
175
176impl Default for ToolRules {
177 fn default() -> Self {
178 Self {
179 default_mode: ToolPermissionMode::Confirm,
180 always_allow: Vec::new(),
181 always_deny: Vec::new(),
182 always_confirm: Vec::new(),
183 invalid_patterns: Vec::new(),
184 }
185 }
186}
187
188#[derive(Clone)]
189pub struct CompiledRegex {
190 pub pattern: String,
191 pub case_sensitive: bool,
192 pub regex: regex::Regex,
193}
194
195impl std::fmt::Debug for CompiledRegex {
196 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
197 f.debug_struct("CompiledRegex")
198 .field("pattern", &self.pattern)
199 .field("case_sensitive", &self.case_sensitive)
200 .finish()
201 }
202}
203
204impl CompiledRegex {
205 pub fn new(pattern: &str, case_sensitive: bool) -> Option<Self> {
206 Self::try_new(pattern, case_sensitive).ok()
207 }
208
209 pub fn try_new(pattern: &str, case_sensitive: bool) -> Result<Self, regex::Error> {
210 let regex = regex::RegexBuilder::new(pattern)
211 .case_insensitive(!case_sensitive)
212 .build()?;
213 Ok(Self {
214 pattern: pattern.to_string(),
215 case_sensitive,
216 regex,
217 })
218 }
219
220 pub fn is_match(&self, input: &str) -> bool {
221 self.regex.is_match(input)
222 }
223}
224
225impl Settings for AgentSettings {
226 fn from_settings(content: &settings::SettingsContent) -> Self {
227 let agent = content.agent.clone().unwrap();
228 Self {
229 enabled: agent.enabled.unwrap(),
230 button: agent.button.unwrap(),
231 dock: agent.dock.unwrap(),
232 agents_panel_dock: agent.agents_panel_dock.unwrap(),
233 default_width: px(agent.default_width.unwrap()),
234 default_height: px(agent.default_height.unwrap()),
235 default_model: Some(agent.default_model.unwrap()),
236 inline_assistant_model: agent.inline_assistant_model,
237 inline_assistant_use_streaming_tools: agent
238 .inline_assistant_use_streaming_tools
239 .unwrap_or(true),
240 commit_message_model: agent.commit_message_model,
241 thread_summary_model: agent.thread_summary_model,
242 inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
243 favorite_models: agent.favorite_models,
244 default_profile: AgentProfileId(agent.default_profile.unwrap()),
245 default_view: agent.default_view.unwrap(),
246 profiles: agent
247 .profiles
248 .unwrap()
249 .into_iter()
250 .map(|(key, val)| (AgentProfileId(key), val.into()))
251 .collect(),
252 always_allow_tool_actions: agent.always_allow_tool_actions.unwrap(),
253 notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
254 play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(),
255 single_file_review: agent.single_file_review.unwrap(),
256 model_parameters: agent.model_parameters,
257 enable_feedback: agent.enable_feedback.unwrap(),
258 expand_edit_card: agent.expand_edit_card.unwrap(),
259 expand_terminal_card: agent.expand_terminal_card.unwrap(),
260 cancel_generation_on_terminal_stop: agent.cancel_generation_on_terminal_stop.unwrap(),
261 use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
262 message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
263 show_turn_stats: agent.show_turn_stats.unwrap(),
264 tool_permissions: compile_tool_permissions(agent.tool_permissions),
265 }
266 }
267}
268
269fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
270 let Some(content) = content else {
271 return ToolPermissions::default();
272 };
273
274 let tools = content
275 .tools
276 .into_iter()
277 .map(|(tool_name, rules_content)| {
278 let mut invalid_patterns = Vec::new();
279
280 let (always_allow, allow_errors) = compile_regex_rules(
281 rules_content.always_allow.map(|v| v.0).unwrap_or_default(),
282 "always_allow",
283 );
284 invalid_patterns.extend(allow_errors);
285
286 let (always_deny, deny_errors) = compile_regex_rules(
287 rules_content.always_deny.map(|v| v.0).unwrap_or_default(),
288 "always_deny",
289 );
290 invalid_patterns.extend(deny_errors);
291
292 let (always_confirm, confirm_errors) = compile_regex_rules(
293 rules_content
294 .always_confirm
295 .map(|v| v.0)
296 .unwrap_or_default(),
297 "always_confirm",
298 );
299 invalid_patterns.extend(confirm_errors);
300
301 // Log invalid patterns for debugging. Users will see an error when they
302 // attempt to use a tool with invalid patterns in their settings.
303 for invalid in &invalid_patterns {
304 log::error!(
305 "Invalid regex pattern in tool_permissions for '{}' tool ({}): '{}' - {}",
306 tool_name,
307 invalid.rule_type,
308 invalid.pattern,
309 invalid.error,
310 );
311 }
312
313 let rules = ToolRules {
314 default_mode: rules_content.default_mode.unwrap_or_default(),
315 always_allow,
316 always_deny,
317 always_confirm,
318 invalid_patterns,
319 };
320 (tool_name, rules)
321 })
322 .collect();
323
324 ToolPermissions { tools }
325}
326
327fn compile_regex_rules(
328 rules: Vec<settings::ToolRegexRule>,
329 rule_type: &str,
330) -> (Vec<CompiledRegex>, Vec<InvalidRegexPattern>) {
331 let mut compiled = Vec::new();
332 let mut errors = Vec::new();
333
334 for rule in rules {
335 let case_sensitive = rule.case_sensitive.unwrap_or(false);
336 match CompiledRegex::try_new(&rule.pattern, case_sensitive) {
337 Ok(regex) => compiled.push(regex),
338 Err(error) => {
339 errors.push(InvalidRegexPattern {
340 pattern: rule.pattern,
341 rule_type: rule_type.to_string(),
342 error: error.to_string(),
343 });
344 }
345 }
346 }
347
348 (compiled, errors)
349}
350
351#[cfg(test)]
352mod tests {
353 use super::*;
354 use serde_json::json;
355 use settings::ToolPermissionsContent;
356
357 #[test]
358 fn test_compiled_regex_case_insensitive() {
359 let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
360 assert!(regex.is_match("rm -rf /"));
361 assert!(regex.is_match("RM -RF /"));
362 assert!(regex.is_match("Rm -Rf /"));
363 }
364
365 #[test]
366 fn test_compiled_regex_case_sensitive() {
367 let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
368 assert!(regex.is_match("DROP TABLE users"));
369 assert!(!regex.is_match("drop table users"));
370 }
371
372 #[test]
373 fn test_invalid_regex_returns_none() {
374 let result = CompiledRegex::new("[invalid(regex", false);
375 assert!(result.is_none());
376 }
377
378 #[test]
379 fn test_tool_permissions_parsing() {
380 let json = json!({
381 "tools": {
382 "terminal": {
383 "default_mode": "allow",
384 "always_deny": [
385 { "pattern": "rm\\s+-rf" }
386 ],
387 "always_allow": [
388 { "pattern": "^git\\s" }
389 ]
390 }
391 }
392 });
393
394 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
395 let permissions = compile_tool_permissions(Some(content));
396
397 let terminal_rules = permissions.tools.get("terminal").unwrap();
398 assert_eq!(terminal_rules.default_mode, ToolPermissionMode::Allow);
399 assert_eq!(terminal_rules.always_deny.len(), 1);
400 assert_eq!(terminal_rules.always_allow.len(), 1);
401 assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
402 assert!(terminal_rules.always_allow[0].is_match("git status"));
403 }
404
405 #[test]
406 fn test_tool_rules_default_mode() {
407 let json = json!({
408 "tools": {
409 "edit_file": {
410 "default_mode": "deny"
411 }
412 }
413 });
414
415 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
416 let permissions = compile_tool_permissions(Some(content));
417
418 let rules = permissions.tools.get("edit_file").unwrap();
419 assert_eq!(rules.default_mode, ToolPermissionMode::Deny);
420 }
421
422 #[test]
423 fn test_tool_permissions_empty() {
424 let permissions = compile_tool_permissions(None);
425 assert!(permissions.tools.is_empty());
426 }
427
428 #[test]
429 fn test_tool_rules_default_returns_confirm() {
430 let default_rules = ToolRules::default();
431 assert_eq!(default_rules.default_mode, ToolPermissionMode::Confirm);
432 assert!(default_rules.always_allow.is_empty());
433 assert!(default_rules.always_deny.is_empty());
434 assert!(default_rules.always_confirm.is_empty());
435 }
436
437 #[test]
438 fn test_tool_permissions_with_multiple_tools() {
439 let json = json!({
440 "tools": {
441 "terminal": {
442 "default_mode": "allow",
443 "always_deny": [{ "pattern": "rm\\s+-rf" }]
444 },
445 "edit_file": {
446 "default_mode": "confirm",
447 "always_deny": [{ "pattern": "\\.env$" }]
448 },
449 "delete_path": {
450 "default_mode": "deny"
451 }
452 }
453 });
454
455 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
456 let permissions = compile_tool_permissions(Some(content));
457
458 assert_eq!(permissions.tools.len(), 3);
459
460 let terminal = permissions.tools.get("terminal").unwrap();
461 assert_eq!(terminal.default_mode, ToolPermissionMode::Allow);
462 assert_eq!(terminal.always_deny.len(), 1);
463
464 let edit_file = permissions.tools.get("edit_file").unwrap();
465 assert_eq!(edit_file.default_mode, ToolPermissionMode::Confirm);
466 assert!(edit_file.always_deny[0].is_match("secrets.env"));
467
468 let delete_path = permissions.tools.get("delete_path").unwrap();
469 assert_eq!(delete_path.default_mode, ToolPermissionMode::Deny);
470 }
471
472 #[test]
473 fn test_tool_permissions_with_all_rule_types() {
474 let json = json!({
475 "tools": {
476 "terminal": {
477 "always_deny": [{ "pattern": "rm\\s+-rf" }],
478 "always_confirm": [{ "pattern": "sudo\\s" }],
479 "always_allow": [{ "pattern": "^git\\s+status" }]
480 }
481 }
482 });
483
484 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
485 let permissions = compile_tool_permissions(Some(content));
486
487 let terminal = permissions.tools.get("terminal").unwrap();
488 assert_eq!(terminal.always_deny.len(), 1);
489 assert_eq!(terminal.always_confirm.len(), 1);
490 assert_eq!(terminal.always_allow.len(), 1);
491
492 assert!(terminal.always_deny[0].is_match("rm -rf /"));
493 assert!(terminal.always_confirm[0].is_match("sudo apt install"));
494 assert!(terminal.always_allow[0].is_match("git status"));
495 }
496
497 #[test]
498 fn test_invalid_regex_is_tracked_and_valid_ones_still_compile() {
499 let json = json!({
500 "tools": {
501 "terminal": {
502 "always_deny": [
503 { "pattern": "[invalid(regex" },
504 { "pattern": "valid_pattern" }
505 ],
506 "always_allow": [
507 { "pattern": "[another_bad" }
508 ]
509 }
510 }
511 });
512
513 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
514 let permissions = compile_tool_permissions(Some(content));
515
516 let terminal = permissions.tools.get("terminal").unwrap();
517
518 // Valid patterns should still be compiled
519 assert_eq!(terminal.always_deny.len(), 1);
520 assert!(terminal.always_deny[0].is_match("valid_pattern"));
521
522 // Invalid patterns should be tracked (order depends on processing order)
523 assert_eq!(terminal.invalid_patterns.len(), 2);
524
525 let deny_invalid = terminal
526 .invalid_patterns
527 .iter()
528 .find(|p| p.rule_type == "always_deny")
529 .expect("should have invalid pattern from always_deny");
530 assert_eq!(deny_invalid.pattern, "[invalid(regex");
531 assert!(!deny_invalid.error.is_empty());
532
533 let allow_invalid = terminal
534 .invalid_patterns
535 .iter()
536 .find(|p| p.rule_type == "always_allow")
537 .expect("should have invalid pattern from always_allow");
538 assert_eq!(allow_invalid.pattern, "[another_bad");
539
540 // ToolPermissions helper methods should work
541 assert!(permissions.has_invalid_patterns());
542 assert_eq!(permissions.invalid_patterns().len(), 2);
543 }
544
545 #[test]
546 fn test_default_json_tool_permissions_parse() {
547 let default_json = include_str!("../../../assets/settings/default.json");
548
549 let value: serde_json::Value = serde_json_lenient::from_str(default_json)
550 .expect("default.json should be valid JSON with comments");
551
552 let agent = value
553 .get("agent")
554 .expect("default.json should have 'agent' key");
555 let tool_permissions = agent
556 .get("tool_permissions")
557 .expect("agent should have 'tool_permissions' key");
558
559 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions.clone())
560 .expect("tool_permissions should parse into ToolPermissionsContent");
561
562 let permissions = compile_tool_permissions(Some(content));
563
564 let terminal = permissions
565 .tools
566 .get("terminal")
567 .expect("terminal tool should be configured");
568 assert!(
569 !terminal.always_deny.is_empty(),
570 "terminal should have deny rules"
571 );
572 assert!(
573 !terminal.always_confirm.is_empty(),
574 "terminal should have confirm rules"
575 );
576 let edit_file = permissions
577 .tools
578 .get("edit_file")
579 .expect("edit_file tool should be configured");
580 assert!(
581 !edit_file.always_deny.is_empty(),
582 "edit_file should have deny rules"
583 );
584
585 let delete_path = permissions
586 .tools
587 .get("delete_path")
588 .expect("delete_path tool should be configured");
589 assert!(
590 !delete_path.always_deny.is_empty(),
591 "delete_path should have deny rules"
592 );
593
594 let fetch = permissions
595 .tools
596 .get("fetch")
597 .expect("fetch tool should be configured");
598 assert_eq!(
599 fetch.default_mode,
600 settings::ToolPermissionMode::Confirm,
601 "fetch should have confirm as default mode"
602 );
603 }
604
605 #[test]
606 fn test_default_deny_rules_match_dangerous_commands() {
607 let default_json = include_str!("../../../assets/settings/default.json");
608 let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
609 let tool_permissions = value["agent"]["tool_permissions"].clone();
610 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
611 let permissions = compile_tool_permissions(Some(content));
612
613 let terminal = permissions.tools.get("terminal").unwrap();
614
615 let dangerous_commands = [
616 "rm -rf /",
617 "rm -rf ~",
618 "rm -rf ..",
619 "mkfs.ext4 /dev/sda",
620 "dd if=/dev/zero of=/dev/sda",
621 "cat /etc/passwd",
622 "cat /etc/shadow",
623 "del /f /s /q c:\\",
624 "format c:",
625 "rd /s /q c:\\windows",
626 ];
627
628 for cmd in &dangerous_commands {
629 assert!(
630 terminal.always_deny.iter().any(|r| r.is_match(cmd)),
631 "Command '{}' should be blocked by deny rules",
632 cmd
633 );
634 }
635 }
636
637 #[test]
638 fn test_deny_takes_precedence_over_allow_and_confirm() {
639 let json = json!({
640 "tools": {
641 "terminal": {
642 "default_mode": "allow",
643 "always_deny": [{ "pattern": "dangerous" }],
644 "always_confirm": [{ "pattern": "dangerous" }],
645 "always_allow": [{ "pattern": "dangerous" }]
646 }
647 }
648 });
649
650 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
651 let permissions = compile_tool_permissions(Some(content));
652 let terminal = permissions.tools.get("terminal").unwrap();
653
654 assert!(
655 terminal.always_deny[0].is_match("run dangerous command"),
656 "Deny rule should match"
657 );
658 assert!(
659 terminal.always_allow[0].is_match("run dangerous command"),
660 "Allow rule should also match (but deny takes precedence at evaluation time)"
661 );
662 assert!(
663 terminal.always_confirm[0].is_match("run dangerous command"),
664 "Confirm rule should also match (but deny takes precedence at evaluation time)"
665 );
666 }
667
668 #[test]
669 fn test_confirm_takes_precedence_over_allow() {
670 let json = json!({
671 "tools": {
672 "terminal": {
673 "default_mode": "allow",
674 "always_confirm": [{ "pattern": "risky" }],
675 "always_allow": [{ "pattern": "risky" }]
676 }
677 }
678 });
679
680 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
681 let permissions = compile_tool_permissions(Some(content));
682 let terminal = permissions.tools.get("terminal").unwrap();
683
684 assert!(
685 terminal.always_confirm[0].is_match("do risky thing"),
686 "Confirm rule should match"
687 );
688 assert!(
689 terminal.always_allow[0].is_match("do risky thing"),
690 "Allow rule should also match (but confirm takes precedence at evaluation time)"
691 );
692 }
693
694 #[test]
695 fn test_regex_matches_anywhere_in_string_not_just_anchored() {
696 let json = json!({
697 "tools": {
698 "terminal": {
699 "always_deny": [
700 { "pattern": "rm\\s+-rf" },
701 { "pattern": "/etc/passwd" }
702 ]
703 }
704 }
705 });
706
707 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
708 let permissions = compile_tool_permissions(Some(content));
709 let terminal = permissions.tools.get("terminal").unwrap();
710
711 assert!(
712 terminal.always_deny[0].is_match("echo hello && rm -rf /"),
713 "Should match rm -rf in the middle of a command chain"
714 );
715 assert!(
716 terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
717 "Should match rm -rf after semicolon"
718 );
719 assert!(
720 terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
721 "Should match /etc/passwd in a pipeline"
722 );
723 assert!(
724 terminal.always_deny[1].is_match("vim /etc/passwd"),
725 "Should match /etc/passwd as argument"
726 );
727 }
728
729 #[test]
730 fn test_fork_bomb_pattern_matches() {
731 let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
732 assert!(
733 fork_bomb_regex.is_match(":(){ :|:& };:"),
734 "Should match the classic fork bomb"
735 );
736 assert!(
737 fork_bomb_regex.is_match(":(){ :|:&};:"),
738 "Should match fork bomb without spaces"
739 );
740 }
741
742 #[test]
743 fn test_default_json_fork_bomb_pattern_matches() {
744 let default_json = include_str!("../../../assets/settings/default.json");
745 let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
746 let tool_permissions = value["agent"]["tool_permissions"].clone();
747 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
748 let permissions = compile_tool_permissions(Some(content));
749
750 let terminal = permissions.tools.get("terminal").unwrap();
751
752 assert!(
753 terminal
754 .always_deny
755 .iter()
756 .any(|r| r.is_match(":(){ :|:& };:")),
757 "Default deny rules should block the classic fork bomb"
758 );
759 }
760
761 #[test]
762 fn test_compiled_regex_stores_case_sensitivity() {
763 let case_sensitive = CompiledRegex::new("test", true).unwrap();
764 let case_insensitive = CompiledRegex::new("test", false).unwrap();
765
766 assert!(case_sensitive.case_sensitive);
767 assert!(!case_insensitive.case_sensitive);
768 }
769
770 #[test]
771 fn test_invalid_regex_is_skipped_not_fail() {
772 let json = json!({
773 "tools": {
774 "terminal": {
775 "always_deny": [
776 { "pattern": "[invalid(regex" },
777 { "pattern": "valid_pattern" }
778 ]
779 }
780 }
781 });
782
783 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
784 let permissions = compile_tool_permissions(Some(content));
785
786 let terminal = permissions.tools.get("terminal").unwrap();
787 assert_eq!(terminal.always_deny.len(), 1);
788 assert!(terminal.always_deny[0].is_match("valid_pattern"));
789 }
790
791 #[test]
792 fn test_unconfigured_tool_not_in_permissions() {
793 let json = json!({
794 "tools": {
795 "terminal": {
796 "default_mode": "allow"
797 }
798 }
799 });
800
801 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
802 let permissions = compile_tool_permissions(Some(content));
803
804 assert!(permissions.tools.contains_key("terminal"));
805 assert!(!permissions.tools.contains_key("edit_file"));
806 assert!(!permissions.tools.contains_key("fetch"));
807 }
808
809 #[test]
810 fn test_always_allow_pattern_only_matches_specified_commands() {
811 // Reproduces user-reported bug: when always_allow has pattern "^echo\s",
812 // only "echo hello" should be allowed, not "git status".
813 //
814 // User config:
815 // always_allow_tool_actions: false
816 // tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
817 let json = json!({
818 "tools": {
819 "terminal": {
820 "always_allow": [
821 { "pattern": "^echo\\s" }
822 ]
823 }
824 }
825 });
826
827 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
828 let permissions = compile_tool_permissions(Some(content));
829
830 let terminal = permissions.tools.get("terminal").unwrap();
831
832 // Verify the pattern was compiled
833 assert_eq!(
834 terminal.always_allow.len(),
835 1,
836 "Should have one always_allow pattern"
837 );
838
839 // Verify the pattern matches "echo hello"
840 assert!(
841 terminal.always_allow[0].is_match("echo hello"),
842 "Pattern ^echo\\s should match 'echo hello'"
843 );
844
845 // Verify the pattern does NOT match "git status"
846 assert!(
847 !terminal.always_allow[0].is_match("git status"),
848 "Pattern ^echo\\s should NOT match 'git status'"
849 );
850
851 // Verify the pattern does NOT match "echoHello" (no space)
852 assert!(
853 !terminal.always_allow[0].is_match("echoHello"),
854 "Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
855 );
856
857 // Verify default_mode is Confirm (the default)
858 assert_eq!(
859 terminal.default_mode,
860 settings::ToolPermissionMode::Confirm,
861 "default_mode should be Confirm when not specified"
862 );
863 }
864}