agent_settings.rs

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