Skip to repository content1459 lines · 53.5 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:47:13.461Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
tool_permissions_setup.rs
1use agent::{AgentTool, TerminalTool, ToolPermissionDecision};
2use agent_settings::AgentSettings;
3use gpui::{
4 Focusable, HighlightStyle, ReadGlobal, ScrollHandle, StyledText, TextStyleRefinement, point,
5 prelude::*,
6};
7use settings::{Settings as _, SettingsStore, ToolPermissionMode};
8use shell_command_parser::extract_commands;
9use std::sync::Arc;
10use theme_settings::ThemeSettings;
11use ui::{Banner, ContextMenu, Divider, PopoverMenu, Severity, Tooltip, prelude::*};
12use util::ResultExt as _;
13use util::shell::ShellKind;
14
15use crate::{SettingsWindow, components::SettingsInputField};
16
17const HARDCODED_RULES_DESCRIPTION: &str =
18 "`rm -rf` commands are always blocked when run on `$HOME`, `~`, `.`, `..`, or `/`";
19const SETTINGS_DISCLAIMER: &str = "Note: custom tool permissions only apply to the native agent and don’t extend to external agents connected through the Agent Client Protocol (ACP).";
20
21/// Tools that support permission rules
22const TOOLS: &[ToolInfo] = &[
23 ToolInfo {
24 id: "terminal",
25 name: "Terminal",
26 description: "Commands executed in the terminal",
27 regex_explanation: "Patterns are matched against each command in the input. Commands chained with &&, ||, ;, or pipes are split and checked individually.",
28 },
29 ToolInfo {
30 id: "edit_file",
31 name: "Edit File",
32 description: "File editing operations",
33 regex_explanation: "Patterns are matched against the file path being edited.",
34 },
35 ToolInfo {
36 id: "write_file",
37 name: "Write File",
38 description: "File creation and overwrite operations",
39 regex_explanation: "Patterns are matched against the file path being written.",
40 },
41 ToolInfo {
42 id: "delete_path",
43 name: "Delete Path",
44 description: "File and directory deletion",
45 regex_explanation: "Patterns are matched against the path being deleted.",
46 },
47 ToolInfo {
48 id: "copy_path",
49 name: "Copy Path",
50 description: "File and directory copying",
51 regex_explanation: "Patterns are matched independently against the source path and the destination path. Enter either path below to test.",
52 },
53 ToolInfo {
54 id: "move_path",
55 name: "Move Path",
56 description: "File and directory moves/renames",
57 regex_explanation: "Patterns are matched independently against the source path and the destination path. Enter either path below to test.",
58 },
59 ToolInfo {
60 id: "create_directory",
61 name: "Create Directory",
62 description: "Directory creation",
63 regex_explanation: "Patterns are matched against the directory path being created.",
64 },
65 ToolInfo {
66 id: "fetch",
67 name: "Fetch",
68 description: "HTTP requests to URLs",
69 regex_explanation: "Patterns are matched against the URL being fetched.",
70 },
71 ToolInfo {
72 id: "search_web",
73 name: "Web Search",
74 description: "Web search queries",
75 regex_explanation: "Patterns are matched against the search query.",
76 },
77 ToolInfo {
78 id: "skill",
79 name: "Skill",
80 description: "Loading agent skill instructions",
81 regex_explanation: "Patterns are matched against the absolute path to the skill's SKILL.md file.",
82 },
83];
84
85pub(crate) struct ToolInfo {
86 id: &'static str,
87 name: &'static str,
88 description: &'static str,
89 regex_explanation: &'static str,
90}
91
92const fn const_str_eq(a: &str, b: &str) -> bool {
93 let a = a.as_bytes();
94 let b = b.as_bytes();
95 if a.len() != b.len() {
96 return false;
97 }
98 let mut i = 0;
99 while i < a.len() {
100 if a[i] != b[i] {
101 return false;
102 }
103 i += 1;
104 }
105 true
106}
107
108/// Finds the index of a tool in `TOOLS` by its ID. Panics (compile error in
109/// const context) if the ID is not found, so every macro-generated render
110/// function is validated at compile time.
111const fn tool_index(id: &str) -> usize {
112 let mut i = 0;
113 while i < TOOLS.len() {
114 if const_str_eq(TOOLS[i].id, id) {
115 return i;
116 }
117 i += 1;
118 }
119 panic!("tool ID not found in TOOLS array")
120}
121
122/// Parses a string containing backtick-delimited code spans into a `StyledText`
123/// with code background highlights applied to each span.
124fn render_inline_code_markdown(text: &str, cx: &App) -> StyledText {
125 let code_background = cx.theme().colors().surface_background;
126 let mut plain = String::new();
127 let mut highlights: Vec<(std::ops::Range<usize>, HighlightStyle)> = Vec::new();
128 let mut in_code = false;
129 let mut code_start = 0;
130
131 for ch in text.chars() {
132 if ch == '`' {
133 if in_code {
134 highlights.push((
135 code_start..plain.len(),
136 HighlightStyle {
137 background_color: Some(code_background),
138 ..Default::default()
139 },
140 ));
141 } else {
142 code_start = plain.len();
143 }
144 in_code = !in_code;
145 } else {
146 plain.push(ch);
147 }
148 }
149
150 StyledText::new(plain).with_highlights(highlights)
151}
152
153/// Renders the main tool permissions setup page showing a list of tools
154pub(crate) fn render_tool_permissions_setup_page(
155 settings_window: &SettingsWindow,
156 scroll_handle: &ScrollHandle,
157 window: &mut Window,
158 cx: &mut Context<SettingsWindow>,
159) -> AnyElement {
160 let tool_items: Vec<AnyElement> = TOOLS
161 .iter()
162 .enumerate()
163 .map(|(i, tool)| render_tool_list_item(settings_window, tool, i, window, cx))
164 .collect();
165
166 let settings = AgentSettings::get_global(cx);
167 let global_default = settings.tool_permissions.default;
168
169 let scroll_step = px(40.);
170
171 v_flex()
172 .id("tool-permissions-page")
173 .on_action({
174 let scroll_handle = scroll_handle.clone();
175 move |_: &menu::SelectNext, window, cx| {
176 window.focus_next(cx);
177 let current_offset = scroll_handle.offset();
178 scroll_handle.set_offset(point(current_offset.x, current_offset.y - scroll_step));
179 }
180 })
181 .on_action({
182 let scroll_handle = scroll_handle.clone();
183 move |_: &menu::SelectPrevious, window, cx| {
184 window.focus_prev(cx);
185 let current_offset = scroll_handle.offset();
186 scroll_handle.set_offset(point(current_offset.x, current_offset.y + scroll_step));
187 }
188 })
189 .min_w_0()
190 .size_full()
191 .pt_2p5()
192 .px_8()
193 .pb_16()
194 .overflow_y_scroll()
195 .track_scroll(scroll_handle)
196 .child(
197 Banner::new().child(
198 Label::new(SETTINGS_DISCLAIMER)
199 .size(LabelSize::Small)
200 .color(Color::Muted)
201 .mt_0p5(),
202 ),
203 )
204 .child(
205 v_flex()
206 .child(render_global_default_mode_section(global_default))
207 .child(Divider::horizontal())
208 .children(tool_items.into_iter().enumerate().flat_map(|(i, item)| {
209 let mut elements: Vec<AnyElement> = vec![item];
210 if i + 1 < TOOLS.len() {
211 elements.push(Divider::horizontal().into_any_element());
212 }
213 elements
214 })),
215 )
216 .into_any_element()
217}
218
219fn render_tool_list_item(
220 _settings_window: &SettingsWindow,
221 tool: &'static ToolInfo,
222 tool_index: usize,
223 _window: &mut Window,
224 cx: &mut Context<SettingsWindow>,
225) -> AnyElement {
226 let rules = get_tool_rules(tool.id, cx);
227 let rule_count =
228 rules.always_allow.len() + rules.always_deny.len() + rules.always_confirm.len();
229 let invalid_count = rules.invalid_patterns.len();
230
231 let rule_summary = if rule_count > 0 || invalid_count > 0 {
232 let mut parts = Vec::new();
233 if rule_count > 0 {
234 if rule_count == 1 {
235 parts.push("1 rule".to_string());
236 } else {
237 parts.push(format!("{} rules", rule_count));
238 }
239 }
240 if invalid_count > 0 {
241 parts.push(format!("{} invalid", invalid_count));
242 }
243 Some(parts.join(", "))
244 } else {
245 None
246 };
247
248 let render_fn = get_tool_render_fn(tool.id);
249
250 h_flex()
251 .w_full()
252 .min_w_0()
253 .py_3()
254 .justify_between()
255 .child(
256 v_flex()
257 .w_full()
258 .min_w_0()
259 .child(h_flex().gap_1().child(Label::new(tool.name)).when_some(
260 rule_summary,
261 |this, summary| {
262 this.child(
263 Label::new(summary)
264 .size(LabelSize::Small)
265 .color(Color::Muted),
266 )
267 },
268 ))
269 .child(
270 Label::new(tool.description)
271 .size(LabelSize::Small)
272 .color(Color::Muted),
273 ),
274 )
275 .child({
276 let tool_name = tool.name;
277 Button::new(format!("configure-{}", tool.id), "Configure")
278 .tab_index(tool_index as isize)
279 .style(ButtonStyle::OutlinedGhost)
280 .size(ButtonSize::Medium)
281 .end_icon(
282 Icon::new(IconName::ChevronRight)
283 .size(IconSize::Small)
284 .color(Color::Muted),
285 )
286 .on_click(cx.listener(move |this, _, window, cx| {
287 this.push_dynamic_sub_page(
288 tool_name,
289 "Tool Permissions",
290 None,
291 true,
292 render_fn,
293 window,
294 cx,
295 );
296 }))
297 })
298 .into_any_element()
299}
300
301fn get_tool_render_fn(
302 tool_id: &str,
303) -> fn(&SettingsWindow, &ScrollHandle, &mut Window, &mut Context<SettingsWindow>) -> AnyElement {
304 match tool_id {
305 "terminal" => render_terminal_tool_config,
306 "edit_file" => render_edit_file_tool_config,
307 "write_file" => render_write_file_tool_config,
308 "delete_path" => render_delete_path_tool_config,
309 "copy_path" => render_copy_path_tool_config,
310 "move_path" => render_move_path_tool_config,
311 "create_directory" => render_create_directory_tool_config,
312 "fetch" => render_fetch_tool_config,
313 "search_web" => render_web_search_tool_config,
314 "skill" => render_skill_tool_config,
315 _ => render_terminal_tool_config, // fallback
316 }
317}
318
319/// Renders an individual tool's permission configuration page
320pub(crate) fn render_tool_config_page(
321 tool: &ToolInfo,
322 settings_window: &SettingsWindow,
323 scroll_handle: &ScrollHandle,
324 window: &mut Window,
325 cx: &mut Context<SettingsWindow>,
326) -> AnyElement {
327 let rules = get_tool_rules(tool.id, cx);
328 let page_title = format!("{} Tool", tool.name);
329 let scroll_step = px(80.);
330
331 v_flex()
332 .id(format!("tool-config-page-{}", tool.id))
333 .on_action({
334 let scroll_handle = scroll_handle.clone();
335 move |_: &menu::SelectNext, window, cx| {
336 window.focus_next(cx);
337 let current_offset = scroll_handle.offset();
338 scroll_handle.set_offset(point(current_offset.x, current_offset.y - scroll_step));
339 }
340 })
341 .on_action({
342 let scroll_handle = scroll_handle.clone();
343 move |_: &menu::SelectPrevious, window, cx| {
344 window.focus_prev(cx);
345 let current_offset = scroll_handle.offset();
346 scroll_handle.set_offset(point(current_offset.x, current_offset.y + scroll_step));
347 }
348 })
349 .min_w_0()
350 .size_full()
351 .pt_2p5()
352 .px_8()
353 .pb_16()
354 .overflow_y_scroll()
355 .track_scroll(scroll_handle)
356 .child(
357 v_flex()
358 .min_w_0()
359 .child(Label::new(page_title).size(LabelSize::Large))
360 .child(
361 Label::new(tool.regex_explanation)
362 .size(LabelSize::Small)
363 .color(Color::Muted),
364 ),
365 )
366 .when(tool.id == TerminalTool::NAME, |this| {
367 this.child(render_hardcoded_security_banner(cx))
368 })
369 .child(render_verification_section(tool.id, window, cx))
370 .when_some(
371 settings_window.regex_validation_error.clone(),
372 |this, error| {
373 this.child(
374 Banner::new()
375 .severity(Severity::Warning)
376 .child(Label::new(error).size(LabelSize::Small))
377 .action_slot(
378 Button::new("dismiss-regex-error", "Dismiss")
379 .style(ButtonStyle::Tinted(ui::TintColor::Warning))
380 .on_click(cx.listener(|this, _, _, cx| {
381 this.regex_validation_error = None;
382 cx.notify();
383 })),
384 ),
385 )
386 },
387 )
388 .child(
389 v_flex()
390 .mt_6()
391 .min_w_0()
392 .w_full()
393 .gap_5()
394 .child(render_default_mode_section(tool.id, rules.default, cx))
395 .child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
396 .child(render_rule_section(
397 tool.id,
398 "Always Deny",
399 "If any of these regexes match, the tool action will be denied.",
400 ToolPermissionMode::Deny,
401 &rules.always_deny,
402 cx,
403 ))
404 .child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
405 .child(render_rule_section(
406 tool.id,
407 "Always Allow",
408 "If any of these regexes match, the action will be approved—unless an Always Confirm or Always Deny matches.",
409 ToolPermissionMode::Allow,
410 &rules.always_allow,
411 cx,
412 ))
413 .child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
414 .child(render_rule_section(
415 tool.id,
416 "Always Confirm",
417 "If any of these regexes match, a confirmation will be shown unless an Always Deny regex matches.",
418 ToolPermissionMode::Confirm,
419 &rules.always_confirm,
420 cx,
421 ))
422 .when(!rules.invalid_patterns.is_empty(), |this| {
423 this.child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
424 .child(render_invalid_patterns_section(
425 tool.id,
426 &rules.invalid_patterns,
427 cx,
428 ))
429 }),
430 )
431 .into_any_element()
432}
433
434fn render_hardcoded_rules(smaller_font_size: bool, cx: &App) -> AnyElement {
435 div()
436 .map(|this| {
437 if smaller_font_size {
438 this.text_xs()
439 } else {
440 this.text_sm()
441 }
442 })
443 .text_color(cx.theme().colors().text_muted)
444 .child(render_inline_code_markdown(HARDCODED_RULES_DESCRIPTION, cx))
445 .into_any_element()
446}
447
448fn render_hardcoded_security_banner(cx: &mut Context<SettingsWindow>) -> AnyElement {
449 div()
450 .mt_3()
451 .child(Banner::new().child(render_hardcoded_rules(false, cx)))
452 .into_any_element()
453}
454
455fn render_verification_section(
456 tool_id: &'static str,
457 window: &mut Window,
458 cx: &mut Context<SettingsWindow>,
459) -> AnyElement {
460 let input_id = format!("{}-verification-input", tool_id);
461
462 let editor = window.use_keyed_state(input_id, cx, |window, cx| {
463 let mut editor = editor::Editor::single_line(window, cx);
464 editor.set_placeholder_text("Enter a tool input to test your rules…", window, cx);
465
466 let global_settings = ThemeSettings::get_global(cx);
467 editor.set_text_style_refinement(TextStyleRefinement {
468 font_family: Some(global_settings.buffer_font.family.clone()),
469 font_size: Some(rems(0.75).into()),
470 ..Default::default()
471 });
472
473 editor
474 });
475
476 cx.observe(&editor, |_, _, cx| cx.notify()).detach();
477
478 let focus_handle = editor.focus_handle(cx).tab_index(0).tab_stop(true);
479
480 let current_text = editor.read(cx).text(cx);
481 let (decision, matched_patterns) = if current_text.is_empty() {
482 (None, Vec::new())
483 } else {
484 let matches = find_matched_patterns(tool_id, ¤t_text, cx);
485 let decision = evaluate_test_input(tool_id, ¤t_text, cx);
486 (Some(decision), matches)
487 };
488
489 let default_mode = get_tool_rules(tool_id, cx).default;
490 let is_hardcoded_denial = matches!(
491 &decision,
492 Some(ToolPermissionDecision::Deny(reason))
493 if reason.contains("built-in security rule")
494 );
495 let denial_reason = match &decision {
496 Some(ToolPermissionDecision::Deny(reason))
497 if !reason.is_empty() && !is_hardcoded_denial =>
498 {
499 Some(reason.clone())
500 }
501 _ => None,
502 };
503 let (authoritative_mode, patterns_agree) = match &decision {
504 Some(decision) => {
505 let authoritative = decision_to_mode(decision);
506 let implied = implied_mode_from_patterns(&matched_patterns, default_mode);
507 let agrees = authoritative == implied;
508 if !agrees {
509 log::error!(
510 "Tool permission verdict disagreement for '{}': \
511 engine={}, pattern_preview={}. \
512 Showing authoritative verdict only.",
513 tool_id,
514 mode_display_label(authoritative),
515 mode_display_label(implied),
516 );
517 }
518 (Some(authoritative), agrees)
519 }
520 None => (None, true),
521 };
522
523 let color = cx.theme().colors();
524
525 v_flex()
526 .mt_3()
527 .min_w_0()
528 .gap_2()
529 .child(
530 v_flex()
531 .p_2p5()
532 .gap_1p5()
533 .bg(color.surface_background.opacity(0.15))
534 .border_1()
535 .border_dashed()
536 .border_color(color.border_variant)
537 .rounded_sm()
538 .child(
539 Label::new("Test Your Rules")
540 .color(Color::Muted)
541 .size(LabelSize::Small),
542 )
543 .child(
544 h_flex()
545 .w_full()
546 .h_8()
547 .px_2()
548 .rounded_md()
549 .border_1()
550 .border_color(color.border)
551 .bg(color.editor_background)
552 .track_focus(&focus_handle)
553 .child(editor),
554 )
555 .when_some(authoritative_mode, |this, mode| {
556 this.when(patterns_agree, |this| {
557 if matched_patterns.is_empty() {
558 this.child(
559 Label::new("No regex matches, using the default action.")
560 .size(LabelSize::Small)
561 .color(Color::Muted),
562 )
563 } else {
564 this.child(render_matched_patterns(&matched_patterns, cx))
565 }
566 })
567 .when(!patterns_agree, |this| {
568 if is_hardcoded_denial {
569 this.child(render_hardcoded_rules(true, cx))
570 } else if let Some(reason) = &denial_reason {
571 this.child(
572 Label::new(format!("Denied: {}", reason))
573 .size(LabelSize::XSmall)
574 .color(Color::Warning),
575 )
576 } else {
577 this.child(
578 Label::new(
579 "Pattern preview differs from engine — showing authoritative result.",
580 )
581 .size(LabelSize::XSmall)
582 .color(Color::Warning),
583 )
584 }
585 })
586 .when(is_hardcoded_denial && patterns_agree, |this| {
587 this.child(render_hardcoded_rules(true, cx))
588 })
589 .child(render_verdict_label(mode))
590 .when_some(
591 denial_reason.filter(|_| patterns_agree && !is_hardcoded_denial),
592 |this, reason| {
593 this.child(
594 Label::new(format!("Reason: {}", reason))
595 .size(LabelSize::XSmall)
596 .color(Color::Error),
597 )
598 },
599 )
600 }),
601 )
602 .into_any_element()
603}
604
605#[derive(Clone, Debug)]
606struct MatchedPattern {
607 pattern: String,
608 rule_type: ToolPermissionMode,
609 is_overridden: bool,
610}
611
612fn find_matched_patterns(tool_id: &str, input: &str, cx: &App) -> Vec<MatchedPattern> {
613 let settings = AgentSettings::get_global(cx);
614 let rules = match settings.tool_permissions.tools.get(tool_id) {
615 Some(rules) => rules,
616 None => return Vec::new(),
617 };
618
619 let mut matched = Vec::new();
620
621 // For terminal commands, parse chained commands (&&, ||, ;) so the preview
622 // matches the real permission engine's behavior.
623 // When parsing fails (extract_commands returns None), the real engine
624 // ignores always_allow rules, so we track parse success to mirror that.
625 let (inputs_to_check, allow_enabled) = if tool_id == TerminalTool::NAME {
626 match extract_commands(input) {
627 Some(cmds) => (cmds, true),
628 None => (vec![input.to_string()], false),
629 }
630 } else {
631 (vec![input.to_string()], true)
632 };
633
634 let mut has_deny_match = false;
635 let mut has_confirm_match = false;
636
637 for rule in &rules.always_deny {
638 if inputs_to_check.iter().any(|cmd| rule.is_match(cmd)) {
639 has_deny_match = true;
640 matched.push(MatchedPattern {
641 pattern: rule.pattern.clone(),
642 rule_type: ToolPermissionMode::Deny,
643 is_overridden: false,
644 });
645 }
646 }
647
648 for rule in &rules.always_confirm {
649 if inputs_to_check.iter().any(|cmd| rule.is_match(cmd)) {
650 has_confirm_match = true;
651 matched.push(MatchedPattern {
652 pattern: rule.pattern.clone(),
653 rule_type: ToolPermissionMode::Confirm,
654 is_overridden: has_deny_match,
655 });
656 }
657 }
658
659 // The real engine requires ALL commands to match at least one allow
660 // pattern for the overall verdict to be Allow. Compute that first,
661 // then show individual patterns with correct override status.
662 let all_commands_matched_allow = !inputs_to_check.is_empty()
663 && inputs_to_check
664 .iter()
665 .all(|cmd| rules.always_allow.iter().any(|rule| rule.is_match(cmd)));
666
667 for rule in &rules.always_allow {
668 if inputs_to_check.iter().any(|cmd| rule.is_match(cmd)) {
669 matched.push(MatchedPattern {
670 pattern: rule.pattern.clone(),
671 rule_type: ToolPermissionMode::Allow,
672 is_overridden: !allow_enabled
673 || has_deny_match
674 || has_confirm_match
675 || !all_commands_matched_allow,
676 });
677 }
678 }
679
680 matched
681}
682
683fn render_matched_patterns(patterns: &[MatchedPattern], cx: &App) -> AnyElement {
684 v_flex()
685 .gap_1()
686 .children(patterns.iter().map(|pattern| {
687 let (type_label, color) = match pattern.rule_type {
688 ToolPermissionMode::Deny => ("Always Deny", Color::Error),
689 ToolPermissionMode::Confirm => ("Always Confirm", Color::Warning),
690 ToolPermissionMode::Allow => ("Always Allow", Color::Success),
691 };
692
693 let type_color = if pattern.is_overridden {
694 Color::Muted
695 } else {
696 color
697 };
698
699 h_flex()
700 .gap_1()
701 .child(
702 Label::new(pattern.pattern.clone())
703 .size(LabelSize::Small)
704 .color(Color::Muted)
705 .buffer_font(cx)
706 .when(pattern.is_overridden, |this| this.strikethrough()),
707 )
708 .child(
709 Icon::new(IconName::Dash)
710 .size(IconSize::Small)
711 .color(Color::Custom(cx.theme().colors().icon_muted.opacity(0.4))),
712 )
713 .child(
714 Label::new(type_label)
715 .size(LabelSize::XSmall)
716 .color(type_color)
717 .when(pattern.is_overridden, |this| {
718 this.strikethrough().alpha(0.5)
719 }),
720 )
721 }))
722 .into_any_element()
723}
724
725fn evaluate_test_input(tool_id: &str, input: &str, cx: &App) -> ToolPermissionDecision {
726 let settings = AgentSettings::get_global(cx);
727
728 // ShellKind is only used for terminal tool's hardcoded security rules;
729 // for other tools, the check returns None immediately.
730 ToolPermissionDecision::from_input(
731 tool_id,
732 &[input.to_string()],
733 &settings.tool_permissions,
734 ShellKind::system(),
735 )
736}
737
738fn decision_to_mode(decision: &ToolPermissionDecision) -> ToolPermissionMode {
739 match decision {
740 ToolPermissionDecision::Allow => ToolPermissionMode::Allow,
741 ToolPermissionDecision::Deny(_) => ToolPermissionMode::Deny,
742 ToolPermissionDecision::Confirm => ToolPermissionMode::Confirm,
743 }
744}
745
746fn implied_mode_from_patterns(
747 patterns: &[MatchedPattern],
748 default_mode: ToolPermissionMode,
749) -> ToolPermissionMode {
750 let has_active_deny = patterns
751 .iter()
752 .any(|p| matches!(p.rule_type, ToolPermissionMode::Deny) && !p.is_overridden);
753 let has_active_confirm = patterns
754 .iter()
755 .any(|p| matches!(p.rule_type, ToolPermissionMode::Confirm) && !p.is_overridden);
756 let has_active_allow = patterns
757 .iter()
758 .any(|p| matches!(p.rule_type, ToolPermissionMode::Allow) && !p.is_overridden);
759
760 if has_active_deny {
761 ToolPermissionMode::Deny
762 } else if has_active_confirm {
763 ToolPermissionMode::Confirm
764 } else if has_active_allow {
765 ToolPermissionMode::Allow
766 } else {
767 default_mode
768 }
769}
770
771fn mode_display_label(mode: ToolPermissionMode) -> &'static str {
772 match mode {
773 ToolPermissionMode::Allow => "Allow",
774 ToolPermissionMode::Deny => "Deny",
775 ToolPermissionMode::Confirm => "Confirm",
776 }
777}
778
779fn verdict_color(mode: ToolPermissionMode) -> Color {
780 match mode {
781 ToolPermissionMode::Allow => Color::Success,
782 ToolPermissionMode::Deny => Color::Error,
783 ToolPermissionMode::Confirm => Color::Warning,
784 }
785}
786
787fn render_verdict_label(mode: ToolPermissionMode) -> AnyElement {
788 h_flex()
789 .gap_1()
790 .child(
791 Label::new("Result:")
792 .size(LabelSize::Small)
793 .color(Color::Muted),
794 )
795 .child(
796 Label::new(mode_display_label(mode))
797 .size(LabelSize::Small)
798 .color(verdict_color(mode)),
799 )
800 .into_any_element()
801}
802
803fn render_invalid_patterns_section(
804 tool_id: &'static str,
805 invalid_patterns: &[InvalidPatternView],
806 cx: &mut Context<SettingsWindow>,
807) -> AnyElement {
808 let section_id = format!("{}-invalid-patterns-section", tool_id);
809 let theme_colors = cx.theme().colors();
810
811 v_flex()
812 .id(section_id)
813 .child(
814 h_flex()
815 .gap_1()
816 .child(
817 Icon::new(IconName::Warning)
818 .size(IconSize::Small)
819 .color(Color::Error),
820 )
821 .child(Label::new("Invalid Patterns").color(Color::Error)),
822 )
823 .child(
824 Label::new(
825 "These patterns failed to compile as regular expressions. \
826 The tool will be blocked until they are fixed or removed.",
827 )
828 .size(LabelSize::Small)
829 .color(Color::Muted),
830 )
831 .child(
832 v_flex()
833 .mt_2()
834 .w_full()
835 .gap_1p5()
836 .children(invalid_patterns.iter().map(|invalid| {
837 let rule_type_label = match invalid.rule_type.as_str() {
838 "always_allow" => "Always Allow",
839 "always_deny" => "Always Deny",
840 "always_confirm" => "Always Confirm",
841 other => other,
842 };
843
844 let pattern_for_delete = invalid.pattern.clone();
845 let rule_type = match invalid.rule_type.as_str() {
846 "always_allow" => ToolPermissionMode::Allow,
847 "always_deny" => ToolPermissionMode::Deny,
848 _ => ToolPermissionMode::Confirm,
849 };
850 let tool_id_for_delete = tool_id.to_string();
851 let delete_id =
852 format!("{}-invalid-delete-{}", tool_id, invalid.pattern.clone());
853
854 v_flex()
855 .p_2()
856 .rounded_md()
857 .border_1()
858 .border_color(theme_colors.border_variant)
859 .bg(theme_colors.surface_background.opacity(0.15))
860 .gap_1()
861 .child(
862 h_flex()
863 .justify_between()
864 .child(
865 h_flex()
866 .gap_1p5()
867 .min_w_0()
868 .child(
869 Label::new(invalid.pattern.clone())
870 .size(LabelSize::Small)
871 .color(Color::Error)
872 .buffer_font(cx),
873 )
874 .child(
875 Label::new(format!("({})", rule_type_label))
876 .size(LabelSize::XSmall)
877 .color(Color::Muted),
878 ),
879 )
880 .child(
881 IconButton::new(delete_id, IconName::Trash)
882 .icon_size(IconSize::Small)
883 .icon_color(Color::Muted)
884 .tooltip(Tooltip::text("Delete Invalid Pattern"))
885 .on_click(cx.listener(move |_, _, _, cx| {
886 delete_pattern(
887 &tool_id_for_delete,
888 rule_type,
889 &pattern_for_delete,
890 cx,
891 );
892 })),
893 ),
894 )
895 .child(
896 Label::new(format!("Error: {}", invalid.error))
897 .size(LabelSize::XSmall)
898 .color(Color::Muted),
899 )
900 })),
901 )
902 .into_any_element()
903}
904
905fn render_rule_section(
906 tool_id: &'static str,
907 title: &'static str,
908 description: &'static str,
909 rule_type: ToolPermissionMode,
910 patterns: &[String],
911 cx: &mut Context<SettingsWindow>,
912) -> AnyElement {
913 let section_id = format!("{}-{:?}-section", tool_id, rule_type);
914
915 let user_patterns: Vec<_> = patterns.iter().enumerate().collect();
916
917 v_flex()
918 .id(section_id)
919 .child(Label::new(title))
920 .child(
921 Label::new(description)
922 .size(LabelSize::Small)
923 .color(Color::Muted),
924 )
925 .child(
926 v_flex()
927 .mt_2()
928 .w_full()
929 .gap_1p5()
930 .when(patterns.is_empty(), |this| {
931 this.child(render_pattern_empty_state(cx))
932 })
933 .when(!user_patterns.is_empty(), |this| {
934 this.child(v_flex().gap_1p5().children(user_patterns.iter().map(
935 |(index, pattern)| {
936 render_user_pattern_row(
937 tool_id,
938 rule_type,
939 *index,
940 (*pattern).clone(),
941 cx,
942 )
943 },
944 )))
945 })
946 .child(render_add_pattern_input(tool_id, rule_type, cx)),
947 )
948 .into_any_element()
949}
950
951fn render_pattern_empty_state(cx: &mut Context<SettingsWindow>) -> AnyElement {
952 h_flex()
953 .p_2()
954 .rounded_md()
955 .border_1()
956 .border_dashed()
957 .border_color(cx.theme().colors().border_variant)
958 .child(
959 Label::new("No patterns configured")
960 .size(LabelSize::Small)
961 .color(Color::Disabled),
962 )
963 .into_any_element()
964}
965
966fn render_user_pattern_row(
967 tool_id: &'static str,
968 rule_type: ToolPermissionMode,
969 index: usize,
970 pattern: String,
971 cx: &mut Context<SettingsWindow>,
972) -> AnyElement {
973 let pattern_for_delete = pattern.clone();
974 let pattern_for_update = pattern.clone();
975 let tool_id_for_delete = tool_id.to_string();
976 let tool_id_for_update = tool_id.to_string();
977 let input_id = format!("{}-{:?}-pattern-{}", tool_id, rule_type, index);
978 let delete_id = format!("{}-{:?}-delete-{}", tool_id, rule_type, index);
979 let settings_window = cx.entity().downgrade();
980
981 SettingsInputField::new(input_id)
982 .with_initial_text(pattern)
983 .tab_index(0)
984 .with_buffer_font()
985 .color(Color::Default)
986 .action_slot(
987 IconButton::new(delete_id, IconName::Trash)
988 .icon_size(IconSize::Small)
989 .icon_color(Color::Muted)
990 .tooltip(Tooltip::text("Delete Pattern"))
991 .on_click(cx.listener(move |_, _, _, cx| {
992 delete_pattern(&tool_id_for_delete, rule_type, &pattern_for_delete, cx);
993 })),
994 )
995 .on_confirm(move |new_pattern, _window, cx| {
996 if let Some(new_pattern) = new_pattern {
997 let new_pattern = new_pattern.trim().to_string();
998 if !new_pattern.is_empty() && new_pattern != pattern_for_update {
999 let updated = update_pattern(
1000 &tool_id_for_update,
1001 rule_type,
1002 &pattern_for_update,
1003 new_pattern.clone(),
1004 cx,
1005 );
1006
1007 let validation_error = if !updated {
1008 Some(
1009 "A pattern with that name already exists in this rule list."
1010 .to_string(),
1011 )
1012 } else {
1013 match regex::Regex::new(&new_pattern) {
1014 Err(err) => Some(format!(
1015 "Invalid regex: {err}. Pattern saved but will block this tool until fixed or removed."
1016 )),
1017 Ok(_) => None,
1018 }
1019 };
1020 settings_window
1021 .update(cx, |this, cx| {
1022 this.regex_validation_error = validation_error;
1023 cx.notify();
1024 })
1025 .log_err();
1026 }
1027 }
1028 })
1029 .into_any_element()
1030}
1031
1032fn render_add_pattern_input(
1033 tool_id: &'static str,
1034 rule_type: ToolPermissionMode,
1035 cx: &mut Context<SettingsWindow>,
1036) -> AnyElement {
1037 let tool_id_owned = tool_id.to_string();
1038 let input_id = format!("{}-{:?}-new-pattern", tool_id, rule_type);
1039 let settings_window = cx.entity().downgrade();
1040
1041 SettingsInputField::new(input_id)
1042 .with_placeholder("Add regex pattern…")
1043 .tab_index(0)
1044 .with_buffer_font()
1045 .display_clear_button()
1046 .display_confirm_button()
1047 .clear_on_confirm()
1048 .on_confirm(move |pattern, _window, cx| {
1049 if let Some(pattern) = pattern {
1050 let trimmed = pattern.trim().to_string();
1051 if !trimmed.is_empty() {
1052 save_pattern(&tool_id_owned, rule_type, trimmed.clone(), cx);
1053
1054 let validation_error = match regex::Regex::new(&trimmed) {
1055 Err(err) => Some(format!(
1056 "Invalid regex: {err}. Pattern saved but will block this tool until fixed or removed."
1057 )),
1058 Ok(_) => None,
1059 };
1060 settings_window
1061 .update(cx, |this, cx| {
1062 this.regex_validation_error = validation_error;
1063 cx.notify();
1064 })
1065 .log_err();
1066 }
1067 }
1068 })
1069 .into_any_element()
1070}
1071
1072fn render_global_default_mode_section(current_mode: ToolPermissionMode) -> AnyElement {
1073 let mode_label = current_mode.to_string();
1074
1075 h_flex()
1076 .my_4()
1077 .min_w_0()
1078 .justify_between()
1079 .child(
1080 v_flex()
1081 .w_full()
1082 .min_w_0()
1083 .child(Label::new("Default Permission"))
1084 .child(
1085 Label::new(
1086 "Controls the default behavior for all tool actions. Per-tool rules and patterns can override this.",
1087 )
1088 .size(LabelSize::Small)
1089 .color(Color::Muted),
1090 ),
1091 )
1092 .child(
1093 PopoverMenu::new("global-default-mode")
1094 .trigger(
1095 Button::new("global-mode-trigger", mode_label)
1096 .tab_index(0_isize)
1097 .style(ButtonStyle::Outlined)
1098 .size(ButtonSize::Medium)
1099 .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)),
1100 )
1101 .menu(move |window, cx| {
1102 Some(ContextMenu::build(window, cx, move |menu, _, _| {
1103 menu.entry("Confirm", None, move |_, cx| {
1104 set_global_default_permission(ToolPermissionMode::Confirm, cx);
1105 })
1106 .entry("Allow", None, move |_, cx| {
1107 set_global_default_permission(ToolPermissionMode::Allow, cx);
1108 })
1109 .entry("Deny", None, move |_, cx| {
1110 set_global_default_permission(ToolPermissionMode::Deny, cx);
1111 })
1112 }))
1113 })
1114 .anchor(gpui::Anchor::TopRight),
1115 )
1116 .into_any_element()
1117}
1118
1119fn render_default_mode_section(
1120 tool_id: &'static str,
1121 current_mode: ToolPermissionMode,
1122 _cx: &mut Context<SettingsWindow>,
1123) -> AnyElement {
1124 let mode_label = match current_mode {
1125 ToolPermissionMode::Allow => "Allow",
1126 ToolPermissionMode::Deny => "Deny",
1127 ToolPermissionMode::Confirm => "Confirm",
1128 };
1129
1130 let tool_id_owned = tool_id.to_string();
1131
1132 h_flex()
1133 .min_w_0()
1134 .justify_between()
1135 .child(
1136 v_flex()
1137 .w_full()
1138 .min_w_0()
1139 .child(Label::new("Default Action"))
1140 .child(
1141 Label::new("Action to take when no patterns match.")
1142 .size(LabelSize::Small)
1143 .color(Color::Muted),
1144 ),
1145 )
1146 .child(
1147 PopoverMenu::new(format!("default-mode-{}", tool_id))
1148 .trigger(
1149 Button::new(format!("mode-trigger-{}", tool_id), mode_label)
1150 .tab_index(0_isize)
1151 .style(ButtonStyle::Outlined)
1152 .size(ButtonSize::Medium)
1153 .end_icon(Icon::new(IconName::ChevronDown).size(IconSize::Small)),
1154 )
1155 .menu(move |window, cx| {
1156 let tool_id = tool_id_owned.clone();
1157 Some(ContextMenu::build(window, cx, move |menu, _, _| {
1158 let tool_id_confirm = tool_id.clone();
1159 let tool_id_allow = tool_id.clone();
1160 let tool_id_deny = tool_id;
1161
1162 menu.entry("Confirm", None, move |_, cx| {
1163 set_default_mode(&tool_id_confirm, ToolPermissionMode::Confirm, cx);
1164 })
1165 .entry("Allow", None, move |_, cx| {
1166 set_default_mode(&tool_id_allow, ToolPermissionMode::Allow, cx);
1167 })
1168 .entry("Deny", None, move |_, cx| {
1169 set_default_mode(&tool_id_deny, ToolPermissionMode::Deny, cx);
1170 })
1171 }))
1172 })
1173 .anchor(gpui::Anchor::TopRight),
1174 )
1175 .into_any_element()
1176}
1177
1178struct InvalidPatternView {
1179 pattern: String,
1180 rule_type: String,
1181 error: String,
1182}
1183
1184struct ToolRulesView {
1185 default: ToolPermissionMode,
1186 always_allow: Vec<String>,
1187 always_deny: Vec<String>,
1188 always_confirm: Vec<String>,
1189 invalid_patterns: Vec<InvalidPatternView>,
1190}
1191
1192fn get_tool_rules(tool_name: &str, cx: &App) -> ToolRulesView {
1193 let settings = AgentSettings::get_global(cx);
1194
1195 let tool_rules = settings.tool_permissions.tools.get(tool_name);
1196
1197 match tool_rules {
1198 Some(rules) => ToolRulesView {
1199 default: rules.default.unwrap_or(settings.tool_permissions.default),
1200 always_allow: rules
1201 .always_allow
1202 .iter()
1203 .map(|r| r.pattern.clone())
1204 .collect(),
1205 always_deny: rules
1206 .always_deny
1207 .iter()
1208 .map(|r| r.pattern.clone())
1209 .collect(),
1210 always_confirm: rules
1211 .always_confirm
1212 .iter()
1213 .map(|r| r.pattern.clone())
1214 .collect(),
1215 invalid_patterns: rules
1216 .invalid_patterns
1217 .iter()
1218 .map(|p| InvalidPatternView {
1219 pattern: p.pattern.clone(),
1220 rule_type: p.rule_type.clone(),
1221 error: p.error.clone(),
1222 })
1223 .collect(),
1224 },
1225 None => ToolRulesView {
1226 default: settings.tool_permissions.default,
1227 always_allow: Vec::new(),
1228 always_deny: Vec::new(),
1229 always_confirm: Vec::new(),
1230 invalid_patterns: Vec::new(),
1231 },
1232 }
1233}
1234
1235fn save_pattern(tool_name: &str, rule_type: ToolPermissionMode, pattern: String, cx: &mut App) {
1236 let tool_name = tool_name.to_string();
1237
1238 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
1239 let tool_permissions = settings
1240 .agent
1241 .get_or_insert_default()
1242 .tool_permissions
1243 .get_or_insert_default();
1244 let tool_rules = tool_permissions
1245 .tools
1246 .entry(Arc::from(tool_name.as_str()))
1247 .or_default();
1248
1249 let rule = settings::ToolRegexRule {
1250 pattern,
1251 case_sensitive: None,
1252 };
1253
1254 let rules_list = match rule_type {
1255 ToolPermissionMode::Allow => tool_rules.always_allow.get_or_insert_default(),
1256 ToolPermissionMode::Deny => tool_rules.always_deny.get_or_insert_default(),
1257 ToolPermissionMode::Confirm => tool_rules.always_confirm.get_or_insert_default(),
1258 };
1259
1260 if !rules_list.0.iter().any(|r| r.pattern == rule.pattern) {
1261 rules_list.0.push(rule);
1262 }
1263 });
1264}
1265
1266fn update_pattern(
1267 tool_name: &str,
1268 rule_type: ToolPermissionMode,
1269 old_pattern: &str,
1270 new_pattern: String,
1271 cx: &mut App,
1272) -> bool {
1273 let settings = AgentSettings::get_global(cx);
1274 if let Some(tool_rules) = settings.tool_permissions.tools.get(tool_name) {
1275 let patterns = match rule_type {
1276 ToolPermissionMode::Allow => &tool_rules.always_allow,
1277 ToolPermissionMode::Deny => &tool_rules.always_deny,
1278 ToolPermissionMode::Confirm => &tool_rules.always_confirm,
1279 };
1280 if patterns.iter().any(|r| r.pattern == new_pattern) {
1281 return false;
1282 }
1283 }
1284
1285 let tool_name = tool_name.to_string();
1286 let old_pattern = old_pattern.to_string();
1287
1288 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
1289 let tool_permissions = settings
1290 .agent
1291 .get_or_insert_default()
1292 .tool_permissions
1293 .get_or_insert_default();
1294
1295 if let Some(tool_rules) = tool_permissions.tools.get_mut(tool_name.as_str()) {
1296 let rules_list = match rule_type {
1297 ToolPermissionMode::Allow => &mut tool_rules.always_allow,
1298 ToolPermissionMode::Deny => &mut tool_rules.always_deny,
1299 ToolPermissionMode::Confirm => &mut tool_rules.always_confirm,
1300 };
1301
1302 if let Some(list) = rules_list {
1303 let already_exists = list.0.iter().any(|r| r.pattern == new_pattern);
1304 if !already_exists {
1305 if let Some(rule) = list.0.iter_mut().find(|r| r.pattern == old_pattern) {
1306 rule.pattern = new_pattern;
1307 }
1308 }
1309 }
1310 }
1311 });
1312
1313 true
1314}
1315
1316fn delete_pattern(tool_name: &str, rule_type: ToolPermissionMode, pattern: &str, cx: &mut App) {
1317 let tool_name = tool_name.to_string();
1318 let pattern = pattern.to_string();
1319
1320 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
1321 let tool_permissions = settings
1322 .agent
1323 .get_or_insert_default()
1324 .tool_permissions
1325 .get_or_insert_default();
1326
1327 if let Some(tool_rules) = tool_permissions.tools.get_mut(tool_name.as_str()) {
1328 let rules_list = match rule_type {
1329 ToolPermissionMode::Allow => &mut tool_rules.always_allow,
1330 ToolPermissionMode::Deny => &mut tool_rules.always_deny,
1331 ToolPermissionMode::Confirm => &mut tool_rules.always_confirm,
1332 };
1333
1334 if let Some(list) = rules_list {
1335 list.0.retain(|r| r.pattern != pattern);
1336 }
1337 }
1338 });
1339}
1340
1341fn set_global_default_permission(mode: ToolPermissionMode, cx: &mut App) {
1342 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
1343 settings
1344 .agent
1345 .get_or_insert_default()
1346 .tool_permissions
1347 .get_or_insert_default()
1348 .default = Some(mode);
1349 });
1350}
1351
1352fn set_default_mode(tool_name: &str, mode: ToolPermissionMode, cx: &mut App) {
1353 let tool_name = tool_name.to_string();
1354
1355 SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
1356 let tool_permissions = settings
1357 .agent
1358 .get_or_insert_default()
1359 .tool_permissions
1360 .get_or_insert_default();
1361 let tool_rules = tool_permissions
1362 .tools
1363 .entry(Arc::from(tool_name.as_str()))
1364 .or_default();
1365 tool_rules.default = Some(mode);
1366 });
1367}
1368
1369macro_rules! tool_config_page_fn {
1370 ($fn_name:ident, $tool_id:literal) => {
1371 pub fn $fn_name(
1372 settings_window: &SettingsWindow,
1373 scroll_handle: &ScrollHandle,
1374 window: &mut Window,
1375 cx: &mut Context<SettingsWindow>,
1376 ) -> AnyElement {
1377 const INDEX: usize = tool_index($tool_id);
1378 render_tool_config_page(&TOOLS[INDEX], settings_window, scroll_handle, window, cx)
1379 }
1380 };
1381}
1382
1383tool_config_page_fn!(render_terminal_tool_config, "terminal");
1384tool_config_page_fn!(render_edit_file_tool_config, "edit_file");
1385tool_config_page_fn!(render_write_file_tool_config, "write_file");
1386tool_config_page_fn!(render_delete_path_tool_config, "delete_path");
1387tool_config_page_fn!(render_copy_path_tool_config, "copy_path");
1388tool_config_page_fn!(render_move_path_tool_config, "move_path");
1389tool_config_page_fn!(render_create_directory_tool_config, "create_directory");
1390tool_config_page_fn!(render_fetch_tool_config, "fetch");
1391tool_config_page_fn!(render_web_search_tool_config, "search_web");
1392tool_config_page_fn!(render_skill_tool_config, "skill");
1393
1394#[cfg(test)]
1395mod tests {
1396 use super::*;
1397
1398 #[test]
1399 fn test_all_tools_are_in_tool_info_or_excluded() {
1400 // Tools that intentionally don't appear in the permissions UI.
1401 // If you add a new tool and this test fails, either:
1402 // 1. Add a ToolInfo entry to TOOLS (if the tool has permission checks), or
1403 // 2. Add it to this list with a comment explaining why it's excluded.
1404 const EXCLUDED_TOOLS: &[&str] = &[
1405 // Read-only / low-risk tools that don't call decide_permission_from_settings
1406 "apply_code_action",
1407 "diagnostics",
1408 "find_path",
1409 "find_references",
1410 "get_code_actions",
1411 "go_to_definition",
1412 "grep",
1413 "list_agents_and_models",
1414 "list_directory",
1415 "open",
1416 "read_file",
1417 "rename_symbol",
1418 "thinking",
1419 // streaming_edit_file uses "edit_file" for permission lookups,
1420 // so its rules are configured under the edit_file entry.
1421 "streaming_edit_file",
1422 // Sibling/subagent thread creation delegates permission checks to
1423 // tool calls inside the spawned thread, not the spawning itself.
1424 "create_thread",
1425 "spawn_agent",
1426 ];
1427
1428 let tool_info_ids: Vec<&str> = TOOLS.iter().map(|t| t.id).collect();
1429
1430 for tool_name in agent::ALL_TOOL_NAMES {
1431 if EXCLUDED_TOOLS.contains(tool_name) {
1432 assert!(
1433 !tool_info_ids.contains(tool_name),
1434 "Tool '{}' is in both EXCLUDED_TOOLS and TOOLS — pick one.",
1435 tool_name,
1436 );
1437 continue;
1438 }
1439 assert!(
1440 tool_info_ids.contains(tool_name),
1441 "Tool '{}' is in ALL_TOOL_NAMES but has no entry in TOOLS and \
1442 is not in EXCLUDED_TOOLS. Either add a ToolInfo entry (if the \
1443 tool has permission checks) or add it to EXCLUDED_TOOLS with \
1444 a comment explaining why.",
1445 tool_name,
1446 );
1447 }
1448
1449 for tool_id in &tool_info_ids {
1450 assert!(
1451 agent::ALL_TOOL_NAMES.contains(tool_id),
1452 "TOOLS contains '{}' but it is not in ALL_TOOL_NAMES. \
1453 Is this a valid built-in tool?",
1454 tool_id,
1455 );
1456 }
1457 }
1458}
1459