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
165#[derive(Clone, Debug)]
166pub struct ToolRules {
167 pub default_mode: ToolPermissionMode,
168 pub always_allow: Vec<CompiledRegex>,
169 pub always_deny: Vec<CompiledRegex>,
170 pub always_confirm: Vec<CompiledRegex>,
171}
172
173impl Default for ToolRules {
174 fn default() -> Self {
175 Self {
176 default_mode: ToolPermissionMode::Confirm,
177 always_allow: Vec::new(),
178 always_deny: Vec::new(),
179 always_confirm: Vec::new(),
180 }
181 }
182}
183
184#[derive(Clone)]
185pub struct CompiledRegex {
186 pub pattern: String,
187 pub case_sensitive: bool,
188 pub regex: regex::Regex,
189}
190
191impl std::fmt::Debug for CompiledRegex {
192 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
193 f.debug_struct("CompiledRegex")
194 .field("pattern", &self.pattern)
195 .field("case_sensitive", &self.case_sensitive)
196 .finish()
197 }
198}
199
200impl CompiledRegex {
201 pub fn new(pattern: &str, case_sensitive: bool) -> Option<Self> {
202 let regex = regex::RegexBuilder::new(pattern)
203 .case_insensitive(!case_sensitive)
204 .build()
205 .map_err(|e| {
206 log::warn!("Invalid regex pattern '{}': {}", pattern, e);
207 e
208 })
209 .ok()?;
210 Some(Self {
211 pattern: pattern.to_string(),
212 case_sensitive,
213 regex,
214 })
215 }
216
217 pub fn is_match(&self, input: &str) -> bool {
218 self.regex.is_match(input)
219 }
220}
221
222impl Settings for AgentSettings {
223 fn from_settings(content: &settings::SettingsContent) -> Self {
224 let agent = content.agent.clone().unwrap();
225 Self {
226 enabled: agent.enabled.unwrap(),
227 button: agent.button.unwrap(),
228 dock: agent.dock.unwrap(),
229 agents_panel_dock: agent.agents_panel_dock.unwrap(),
230 default_width: px(agent.default_width.unwrap()),
231 default_height: px(agent.default_height.unwrap()),
232 default_model: Some(agent.default_model.unwrap()),
233 inline_assistant_model: agent.inline_assistant_model,
234 inline_assistant_use_streaming_tools: agent
235 .inline_assistant_use_streaming_tools
236 .unwrap_or(true),
237 commit_message_model: agent.commit_message_model,
238 thread_summary_model: agent.thread_summary_model,
239 inline_alternatives: agent.inline_alternatives.unwrap_or_default(),
240 favorite_models: agent.favorite_models,
241 default_profile: AgentProfileId(agent.default_profile.unwrap()),
242 default_view: agent.default_view.unwrap(),
243 profiles: agent
244 .profiles
245 .unwrap()
246 .into_iter()
247 .map(|(key, val)| (AgentProfileId(key), val.into()))
248 .collect(),
249 always_allow_tool_actions: agent.always_allow_tool_actions.unwrap(),
250 notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
251 play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(),
252 single_file_review: agent.single_file_review.unwrap(),
253 model_parameters: agent.model_parameters,
254 preferred_completion_mode: agent.preferred_completion_mode.unwrap().into(),
255 enable_feedback: agent.enable_feedback.unwrap(),
256 expand_edit_card: agent.expand_edit_card.unwrap(),
257 expand_terminal_card: agent.expand_terminal_card.unwrap(),
258 use_modifier_to_send: agent.use_modifier_to_send.unwrap(),
259 message_editor_min_lines: agent.message_editor_min_lines.unwrap(),
260 show_turn_stats: agent.show_turn_stats.unwrap(),
261 tool_permissions: compile_tool_permissions(agent.tool_permissions),
262 }
263 }
264}
265
266fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -> ToolPermissions {
267 let Some(content) = content else {
268 return ToolPermissions::default();
269 };
270
271 let tools = content
272 .tools
273 .into_iter()
274 .map(|(tool_name, rules_content)| {
275 let rules = ToolRules {
276 default_mode: rules_content.default_mode.unwrap_or_default(),
277 always_allow: rules_content
278 .always_allow
279 .map(|v| compile_regex_rules(v.0))
280 .unwrap_or_default(),
281 always_deny: rules_content
282 .always_deny
283 .map(|v| compile_regex_rules(v.0))
284 .unwrap_or_default(),
285 always_confirm: rules_content
286 .always_confirm
287 .map(|v| compile_regex_rules(v.0))
288 .unwrap_or_default(),
289 };
290 (tool_name, rules)
291 })
292 .collect();
293
294 ToolPermissions { tools }
295}
296
297fn compile_regex_rules(rules: Vec<settings::ToolRegexRule>) -> Vec<CompiledRegex> {
298 rules
299 .into_iter()
300 .filter_map(|rule| CompiledRegex::new(&rule.pattern, rule.case_sensitive.unwrap_or(false)))
301 .collect()
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use serde_json::json;
308 use settings::ToolPermissionsContent;
309
310 #[test]
311 fn test_compiled_regex_case_insensitive() {
312 let regex = CompiledRegex::new("rm\\s+-rf", false).unwrap();
313 assert!(regex.is_match("rm -rf /"));
314 assert!(regex.is_match("RM -RF /"));
315 assert!(regex.is_match("Rm -Rf /"));
316 }
317
318 #[test]
319 fn test_compiled_regex_case_sensitive() {
320 let regex = CompiledRegex::new("DROP\\s+TABLE", true).unwrap();
321 assert!(regex.is_match("DROP TABLE users"));
322 assert!(!regex.is_match("drop table users"));
323 }
324
325 #[test]
326 fn test_invalid_regex_returns_none() {
327 let result = CompiledRegex::new("[invalid(regex", false);
328 assert!(result.is_none());
329 }
330
331 #[test]
332 fn test_tool_permissions_parsing() {
333 let json = json!({
334 "tools": {
335 "terminal": {
336 "default_mode": "allow",
337 "always_deny": [
338 { "pattern": "rm\\s+-rf" }
339 ],
340 "always_allow": [
341 { "pattern": "^git\\s" }
342 ]
343 }
344 }
345 });
346
347 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
348 let permissions = compile_tool_permissions(Some(content));
349
350 let terminal_rules = permissions.tools.get("terminal").unwrap();
351 assert_eq!(terminal_rules.default_mode, ToolPermissionMode::Allow);
352 assert_eq!(terminal_rules.always_deny.len(), 1);
353 assert_eq!(terminal_rules.always_allow.len(), 1);
354 assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
355 assert!(terminal_rules.always_allow[0].is_match("git status"));
356 }
357
358 #[test]
359 fn test_tool_rules_default_mode() {
360 let json = json!({
361 "tools": {
362 "edit_file": {
363 "default_mode": "deny"
364 }
365 }
366 });
367
368 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
369 let permissions = compile_tool_permissions(Some(content));
370
371 let rules = permissions.tools.get("edit_file").unwrap();
372 assert_eq!(rules.default_mode, ToolPermissionMode::Deny);
373 }
374
375 #[test]
376 fn test_tool_permissions_empty() {
377 let permissions = compile_tool_permissions(None);
378 assert!(permissions.tools.is_empty());
379 }
380
381 #[test]
382 fn test_tool_rules_default_returns_confirm() {
383 let default_rules = ToolRules::default();
384 assert_eq!(default_rules.default_mode, ToolPermissionMode::Confirm);
385 assert!(default_rules.always_allow.is_empty());
386 assert!(default_rules.always_deny.is_empty());
387 assert!(default_rules.always_confirm.is_empty());
388 }
389
390 #[test]
391 fn test_tool_permissions_with_multiple_tools() {
392 let json = json!({
393 "tools": {
394 "terminal": {
395 "default_mode": "allow",
396 "always_deny": [{ "pattern": "rm\\s+-rf" }]
397 },
398 "edit_file": {
399 "default_mode": "confirm",
400 "always_deny": [{ "pattern": "\\.env$" }]
401 },
402 "delete_path": {
403 "default_mode": "deny"
404 }
405 }
406 });
407
408 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
409 let permissions = compile_tool_permissions(Some(content));
410
411 assert_eq!(permissions.tools.len(), 3);
412
413 let terminal = permissions.tools.get("terminal").unwrap();
414 assert_eq!(terminal.default_mode, ToolPermissionMode::Allow);
415 assert_eq!(terminal.always_deny.len(), 1);
416
417 let edit_file = permissions.tools.get("edit_file").unwrap();
418 assert_eq!(edit_file.default_mode, ToolPermissionMode::Confirm);
419 assert!(edit_file.always_deny[0].is_match("secrets.env"));
420
421 let delete_path = permissions.tools.get("delete_path").unwrap();
422 assert_eq!(delete_path.default_mode, ToolPermissionMode::Deny);
423 }
424
425 #[test]
426 fn test_tool_permissions_with_all_rule_types() {
427 let json = json!({
428 "tools": {
429 "terminal": {
430 "always_deny": [{ "pattern": "rm\\s+-rf" }],
431 "always_confirm": [{ "pattern": "sudo\\s" }],
432 "always_allow": [{ "pattern": "^git\\s+status" }]
433 }
434 }
435 });
436
437 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
438 let permissions = compile_tool_permissions(Some(content));
439
440 let terminal = permissions.tools.get("terminal").unwrap();
441 assert_eq!(terminal.always_deny.len(), 1);
442 assert_eq!(terminal.always_confirm.len(), 1);
443 assert_eq!(terminal.always_allow.len(), 1);
444
445 assert!(terminal.always_deny[0].is_match("rm -rf /"));
446 assert!(terminal.always_confirm[0].is_match("sudo apt install"));
447 assert!(terminal.always_allow[0].is_match("git status"));
448 }
449
450 #[test]
451 fn test_invalid_regex_is_skipped_not_fail() {
452 let json = json!({
453 "tools": {
454 "terminal": {
455 "always_deny": [
456 { "pattern": "[invalid(regex" },
457 { "pattern": "valid_pattern" }
458 ]
459 }
460 }
461 });
462
463 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
464 let permissions = compile_tool_permissions(Some(content));
465
466 let terminal = permissions.tools.get("terminal").unwrap();
467 assert_eq!(terminal.always_deny.len(), 1);
468 assert!(terminal.always_deny[0].is_match("valid_pattern"));
469 }
470
471 #[test]
472 fn test_default_json_tool_permissions_parse() {
473 let default_json = include_str!("../../../assets/settings/default.json");
474
475 let value: serde_json::Value = serde_json_lenient::from_str(default_json)
476 .expect("default.json should be valid JSON with comments");
477
478 let agent = value
479 .get("agent")
480 .expect("default.json should have 'agent' key");
481 let tool_permissions = agent
482 .get("tool_permissions")
483 .expect("agent should have 'tool_permissions' key");
484
485 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions.clone())
486 .expect("tool_permissions should parse into ToolPermissionsContent");
487
488 let permissions = compile_tool_permissions(Some(content));
489
490 let terminal = permissions
491 .tools
492 .get("terminal")
493 .expect("terminal tool should be configured");
494 assert!(
495 !terminal.always_deny.is_empty(),
496 "terminal should have deny rules"
497 );
498 assert!(
499 !terminal.always_confirm.is_empty(),
500 "terminal should have confirm rules"
501 );
502 assert!(
503 !terminal.always_allow.is_empty(),
504 "terminal should have allow rules"
505 );
506
507 let edit_file = permissions
508 .tools
509 .get("edit_file")
510 .expect("edit_file tool should be configured");
511 assert!(
512 !edit_file.always_deny.is_empty(),
513 "edit_file should have deny rules"
514 );
515
516 let delete_path = permissions
517 .tools
518 .get("delete_path")
519 .expect("delete_path tool should be configured");
520 assert!(
521 !delete_path.always_deny.is_empty(),
522 "delete_path should have deny rules"
523 );
524
525 let fetch = permissions
526 .tools
527 .get("fetch")
528 .expect("fetch tool should be configured");
529 assert!(
530 !fetch.always_allow.is_empty(),
531 "fetch should have allow rules"
532 );
533 }
534
535 #[test]
536 fn test_default_deny_rules_match_dangerous_commands() {
537 let default_json = include_str!("../../../assets/settings/default.json");
538 let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
539 let tool_permissions = value["agent"]["tool_permissions"].clone();
540 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
541 let permissions = compile_tool_permissions(Some(content));
542
543 let terminal = permissions.tools.get("terminal").unwrap();
544
545 let dangerous_commands = [
546 "rm -rf /",
547 "rm -rf ~",
548 "rm -rf ..",
549 "mkfs.ext4 /dev/sda",
550 "dd if=/dev/zero of=/dev/sda",
551 "cat /etc/passwd",
552 "cat /etc/shadow",
553 "del /f /s /q c:\\",
554 "format c:",
555 "rd /s /q c:\\windows",
556 ];
557
558 for cmd in &dangerous_commands {
559 assert!(
560 terminal.always_deny.iter().any(|r| r.is_match(cmd)),
561 "Command '{}' should be blocked by deny rules",
562 cmd
563 );
564 }
565 }
566
567 #[test]
568 fn test_default_allow_rules_match_safe_commands() {
569 let default_json = include_str!("../../../assets/settings/default.json");
570 let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
571 let tool_permissions = value["agent"]["tool_permissions"].clone();
572 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
573 let permissions = compile_tool_permissions(Some(content));
574
575 let terminal = permissions.tools.get("terminal").unwrap();
576
577 let safe_commands = [
578 "cargo build",
579 "cargo test",
580 "cargo check",
581 "npm test",
582 "pnpm install",
583 "yarn run build",
584 "ls",
585 "ls -la",
586 "cat file.txt",
587 "git status",
588 "git log",
589 "git diff",
590 ];
591
592 for cmd in &safe_commands {
593 assert!(
594 terminal.always_allow.iter().any(|r| r.is_match(cmd)),
595 "Command '{}' should be allowed by allow rules",
596 cmd
597 );
598 }
599 }
600
601 #[test]
602 fn test_deny_takes_precedence_over_allow_and_confirm() {
603 let json = json!({
604 "tools": {
605 "terminal": {
606 "default_mode": "allow",
607 "always_deny": [{ "pattern": "dangerous" }],
608 "always_confirm": [{ "pattern": "dangerous" }],
609 "always_allow": [{ "pattern": "dangerous" }]
610 }
611 }
612 });
613
614 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
615 let permissions = compile_tool_permissions(Some(content));
616 let terminal = permissions.tools.get("terminal").unwrap();
617
618 assert!(
619 terminal.always_deny[0].is_match("run dangerous command"),
620 "Deny rule should match"
621 );
622 assert!(
623 terminal.always_allow[0].is_match("run dangerous command"),
624 "Allow rule should also match (but deny takes precedence at evaluation time)"
625 );
626 assert!(
627 terminal.always_confirm[0].is_match("run dangerous command"),
628 "Confirm rule should also match (but deny takes precedence at evaluation time)"
629 );
630 }
631
632 #[test]
633 fn test_confirm_takes_precedence_over_allow() {
634 let json = json!({
635 "tools": {
636 "terminal": {
637 "default_mode": "allow",
638 "always_confirm": [{ "pattern": "risky" }],
639 "always_allow": [{ "pattern": "risky" }]
640 }
641 }
642 });
643
644 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
645 let permissions = compile_tool_permissions(Some(content));
646 let terminal = permissions.tools.get("terminal").unwrap();
647
648 assert!(
649 terminal.always_confirm[0].is_match("do risky thing"),
650 "Confirm rule should match"
651 );
652 assert!(
653 terminal.always_allow[0].is_match("do risky thing"),
654 "Allow rule should also match (but confirm takes precedence at evaluation time)"
655 );
656 }
657
658 #[test]
659 fn test_regex_matches_anywhere_in_string_not_just_anchored() {
660 let json = json!({
661 "tools": {
662 "terminal": {
663 "always_deny": [
664 { "pattern": "rm\\s+-rf" },
665 { "pattern": "/etc/passwd" }
666 ]
667 }
668 }
669 });
670
671 let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
672 let permissions = compile_tool_permissions(Some(content));
673 let terminal = permissions.tools.get("terminal").unwrap();
674
675 assert!(
676 terminal.always_deny[0].is_match("echo hello && rm -rf /"),
677 "Should match rm -rf in the middle of a command chain"
678 );
679 assert!(
680 terminal.always_deny[0].is_match("cd /tmp; rm -rf *"),
681 "Should match rm -rf after semicolon"
682 );
683 assert!(
684 terminal.always_deny[1].is_match("cat /etc/passwd | grep root"),
685 "Should match /etc/passwd in a pipeline"
686 );
687 assert!(
688 terminal.always_deny[1].is_match("vim /etc/passwd"),
689 "Should match /etc/passwd as argument"
690 );
691 }
692
693 #[test]
694 fn test_fork_bomb_pattern_matches() {
695 let fork_bomb_regex = CompiledRegex::new(r":\(\)\{\s*:\|:&\s*\};:", false).unwrap();
696 assert!(
697 fork_bomb_regex.is_match(":(){ :|:& };:"),
698 "Should match the classic fork bomb"
699 );
700 assert!(
701 fork_bomb_regex.is_match(":(){ :|:&};:"),
702 "Should match fork bomb without spaces"
703 );
704 }
705
706 #[test]
707 fn test_default_json_fork_bomb_pattern_matches() {
708 let default_json = include_str!("../../../assets/settings/default.json");
709 let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
710 let tool_permissions = value["agent"]["tool_permissions"].clone();
711 let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
712 let permissions = compile_tool_permissions(Some(content));
713
714 let terminal = permissions.tools.get("terminal").unwrap();
715
716 assert!(
717 terminal
718 .always_deny
719 .iter()
720 .any(|r| r.is_match(":(){ :|:& };:")),
721 "Default deny rules should block the classic fork bomb"
722 );
723 }
724}