Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:43:12.652Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

tool_permissions_setup.rs

1459 lines · 53.5 KB · rust
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, &current_text, cx);
485        let decision = evaluate_test_input(tool_id, &current_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
Served at tenant.openagents/omega Member data and write actions are omitted.