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