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