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 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}