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