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