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