tool_permissions_setup.rs

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