Tool permissions settings UI improvements (#48796)

Various improvements to settings UI for granular tool permissions,
extracted from a larger change:

- Add **copy_path** tool to the permissions configuration list
- Replace local `RuleType` enum with `ToolPermissionMode` from the
settings crate
- Improve rule summary display: singular "1 rule" instead of always "1
rules", show invalid pattern count
- Add **terminal command parsing preview** — uses `extract_commands` to
split chained commands (`&&`, `||`, `;`) so the preview matches the real
permission engine's behavior
- Add **verdict label** showing the authoritative permission decision
(Allow/Deny/Confirm)
- Log disagreements between the pattern preview and the engine's
authoritative verdict
- Display **invalid regex patterns section** with compilation error
details
- Add regex validation error banner (dismissible) on tool config pages
- Add **compile-time validated** `tool_index` for macro-generated render
functions (fails to compile if a tool ID is misspelled)
- Update move_path/copy_path regex explanations for multi-input tools
- Add test validating all tools have `ToolInfo` entries or are in the
exclusion list
- Add `clear_on_confirm()` — clears the input after confirming (used for
"add pattern" inputs)
- Add editor reconciliation fix — when settings change externally (e.g.
editing settings.json), syncs the cached editor text when not focused
- Rename "Configure Tool Rules" → "Tool Permissions" in the settings
sidebar
- Update description text
- Add `regex_validation_error` field to `SettingsWindow` (cleared on
page navigation)

(No release notes because granular tool permissions are behind a feature
flag.)

Release Notes:

- N/A

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>
This commit is contained in:
Richard FeldmanandDanilo Leal authored and GitHub committed 2026-02-10 10:30:33 -05:00
1 parent 1176c39476
commit 77df6bfef2
7 files changed
+479 -126

No files matched your search

Generated
+2
View File
@@ -15255,6 +15255,7 @@ dependencies = [
"pretty_assertions",
"project",
"recent_projects",
"regex",
"release_channel",
"schemars",
"search",
@@ -15262,6 +15263,7 @@ dependencies = [
"serde_json",
"session",
"settings",
"shell_command_parser",
"strum 0.27.2",
"telemetry",
"theme",
+2
View File
@@ -39,6 +39,7 @@ menu.workspace = true
paths.workspace = true
picker.workspace = true
platform_title_bar.workspace = true
regex.workspace = true
project.workspace = true
release_channel.workspace = true
schemars.workspace = true
@@ -46,6 +47,7 @@ search.workspace = true
serde.workspace = true
serde_json.workspace = true
settings.workspace = true
shell_command_parser.workspace = true
strum.workspace = true
telemetry.workspace = true
theme.workspace = true
@@ -16,6 +16,7 @@ pub struct SettingsInputField {
use_buffer_font: bool,
display_confirm_button: bool,
display_clear_button: bool,
clear_on_confirm: bool,
action_slot: Option<AnyElement>,
color: Option<Color>,
}
@@ -31,6 +32,7 @@ impl SettingsInputField {
use_buffer_font: false,
display_confirm_button: false,
display_clear_button: false,
clear_on_confirm: false,
action_slot: None,
color: None,
}
@@ -69,6 +71,11 @@ impl SettingsInputField {
self
}
pub fn clear_on_confirm(mut self) -> Self {
self.clear_on_confirm = true;
self
}
pub fn action_slot(mut self, action: impl IntoElement) -> Self {
self.action_slot = Some(action.into_any_element());
self
@@ -138,10 +145,26 @@ impl RenderOnce for SettingsInputField {
})
};
// When settings change externally (e.g. editing settings.json), the page
// re-renders but use_keyed_state returns the cached editor with stale text.
// Reconcile with the expected initial_text when the editor is not focused,
// so we don't clobber what the user is actively typing.
if let Some(initial_text) = &self.initial_text {
let current_text = editor.read(cx).text(cx);
if current_text != *initial_text && !editor.read(cx).is_focused(window) {
editor.update(cx, |editor, cx| {
editor.set_text(initial_text.clone(), window, cx);
});
}
}
let weak_editor = editor.downgrade();
let weak_editor_for_button = editor.downgrade();
let weak_editor_for_clear = editor.downgrade();
let clear_on_confirm = self.clear_on_confirm;
let clear_on_confirm_for_button = self.clear_on_confirm;
let theme_colors = cx.theme().colors();
let display_confirm_button = self.display_confirm_button;
@@ -214,6 +237,11 @@ impl RenderOnce for SettingsInputField {
let new_value =
(!new_value.is_empty()).then_some(new_value);
confirm(new_value, window, cx);
if clear_on_confirm_for_button {
editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
});
}
}),
)
},
@@ -229,6 +257,11 @@ impl RenderOnce for SettingsInputField {
let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
let new_value = (!new_value.is_empty()).then_some(new_value);
confirm(new_value, window, cx);
if clear_on_confirm {
editor.update(cx, |editor, cx| {
editor.set_text("", window, cx);
});
}
}
})
})
+2 -2
View File
@@ -6896,10 +6896,10 @@ fn ai_page() -> SettingsPage {
files: USER,
}),
SettingsPageItem::SubPageLink(SubPageLink {
title: "Configure Tool Rules".into(),
title: "Tool Permissions".into(),
r#type: Default::default(),
json_path: Some("agent.tool_permissions"),
description: Some("Set up regex patterns to auto-allow, auto-deny, or always prompt for specific tool inputs.".into()),
description: Some("Set up regex patterns to auto-allow, auto-deny, or always request confirmation, for specific tool inputs.".into()),
in_json: true,
files: USER,
render: render_tool_permissions_setup_page,
@@ -1,14 +1,22 @@
use agent::{HARDCODED_SECURITY_RULES, ToolPermissionDecision};
use agent::{AgentTool, TerminalTool, ToolPermissionDecision};
use agent_settings::AgentSettings;
use gpui::{Focusable, ReadGlobal, ScrollHandle, TextStyleRefinement, point, prelude::*};
use gpui::{
Focusable, HighlightStyle, ReadGlobal, ScrollHandle, StyledText, TextStyleRefinement, point,
prelude::*,
};
use settings::{Settings as _, SettingsStore, ToolPermissionMode};
use shell_command_parser::extract_commands;
use std::sync::Arc;
use theme::ThemeSettings;
use ui::{Banner, ContextMenu, Divider, PopoverMenu, Tooltip, prelude::*};
use ui::{Banner, ContextMenu, Divider, PopoverMenu, Severity, Tooltip, prelude::*};
use util::shell::ShellKind;
use crate::{SettingsWindow, components::SettingsInputField};
const HARDCODED_RULES_DESCRIPTION: &str =
"`rm -rf` commands are always blocked when run on `$HOME`, `~`, `.`, `..`, or `/`";
const 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).";
/// Tools that support permission rules
const TOOLS: &[ToolInfo] = &[
ToolInfo {
@@ -29,11 +37,17 @@ const TOOLS: &[ToolInfo] = &[
description: "File and directory deletion",
regex_explanation: "Patterns are matched against the path being deleted.",
},
ToolInfo {
id: "copy_path",
name: "Copy Path",
description: "File and directory copying",
regex_explanation: "Patterns are matched independently against the source path and the destination path. Enter either path below to test.",
},
ToolInfo {
id: "move_path",
name: "Move Path",
description: "File and directory moves/renames",
regex_explanation: "Patterns are matched against both the source and destination paths.",
regex_explanation: "Patterns are matched independently against the source path and the destination path. Enter either path below to test.",
},
ToolInfo {
id: "create_directory",
@@ -61,13 +75,74 @@ const TOOLS: &[ToolInfo] = &[
},
];
struct ToolInfo {
pub(crate) struct ToolInfo {
id: &'static str,
name: &'static str,
description: &'static str,
regex_explanation: &'static str,
}
const fn const_str_eq(a: &str, b: &str) -> bool {
let a = a.as_bytes();
let b = b.as_bytes();
if a.len() != b.len() {
return false;
}
let mut i = 0;
while i < a.len() {
if a[i] != b[i] {
return false;
}
i += 1;
}
true
}
/// Finds the index of a tool in `TOOLS` by its ID. Panics (compile error in
/// const context) if the ID is not found, so every macro-generated render
/// function is validated at compile time.
const fn tool_index(id: &str) -> usize {
let mut i = 0;
while i < TOOLS.len() {
if const_str_eq(TOOLS[i].id, id) {
return i;
}
i += 1;
}
panic!("tool ID not found in TOOLS array")
}
/// Parses a string containing backtick-delimited code spans into a `StyledText`
/// with code background highlights applied to each span.
fn render_inline_code_markdown(text: &str, cx: &App) -> StyledText {
let code_background = cx.theme().colors().surface_background;
let mut plain = String::new();
let mut highlights: Vec<(std::ops::Range<usize>, HighlightStyle)> = Vec::new();
let mut in_code = false;
let mut code_start = 0;
for ch in text.chars() {
if ch == '`' {
if in_code {
highlights.push((
code_start..plain.len(),
HighlightStyle {
background_color: Some(code_background),
..Default::default()
},
));
} else {
code_start = plain.len();
}
in_code = !in_code;
} else {
plain.push(ch);
}
}
StyledText::new(plain).with_highlights(highlights)
}
/// Renders the main tool permissions setup page showing a list of tools
pub(crate) fn render_tool_permissions_setup_page(
settings_window: &SettingsWindow,
@@ -81,9 +156,6 @@ pub(crate) fn render_tool_permissions_setup_page(
.map(|(i, tool)| render_tool_list_item(settings_window, tool, i, window, cx))
.collect();
let page_description =
"Configure regex patterns to control which tool actions require confirmation.";
let scroll_step = px(40.);
v_flex()
@@ -111,22 +183,22 @@ pub(crate) fn render_tool_permissions_setup_page(
.pb_16()
.overflow_y_scroll()
.track_scroll(scroll_handle)
.child(Label::new("Tool Permission Rules").size(LabelSize::Large))
.child(
Label::new(page_description)
.size(LabelSize::Small)
.color(Color::Muted),
Banner::new().child(
Label::new(SETTINGS_DISCLAIMER)
.size(LabelSize::Small)
.color(Color::Muted)
.mt_0p5(),
),
)
.child(
v_flex()
.mt_4()
.children(tool_items.into_iter().enumerate().flat_map(|(i, item)| {
let mut elements: Vec<AnyElement> = vec![item];
if i + 1 < TOOLS.len() {
elements.push(Divider::horizontal().into_any_element());
}
elements
})),
v_flex().children(tool_items.into_iter().enumerate().flat_map(|(i, item)| {
let mut elements: Vec<AnyElement> = vec![item];
if i + 1 < TOOLS.len() {
elements.push(Divider::horizontal().into_any_element());
}
elements
})),
)
.into_any_element()
}
@@ -141,9 +213,21 @@ fn render_tool_list_item(
let rules = get_tool_rules(tool.id, cx);
let rule_count =
rules.always_allow.len() + rules.always_deny.len() + rules.always_confirm.len();
let invalid_count = rules.invalid_patterns.len();
let rule_summary = if rule_count > 0 {
Some(format!("{} rules", rule_count))
let rule_summary = if rule_count > 0 || invalid_count > 0 {
let mut parts = Vec::new();
if rule_count > 0 {
if rule_count == 1 {
parts.push("1 rule".to_string());
} else {
parts.push(format!("{} rules", rule_count));
}
}
if invalid_count > 0 {
parts.push(format!("{} invalid", invalid_count));
}
Some(parts.join(", "))
} else {
None
};
@@ -185,7 +269,7 @@ fn render_tool_list_item(
.on_click(cx.listener(move |this, _, window, cx| {
this.push_dynamic_sub_page(
tool_name,
"Configure Tool Rules",
"Tool Permissions",
None,
render_fn,
window,
@@ -203,6 +287,7 @@ fn get_tool_render_fn(
"terminal" => render_terminal_tool_config,
"edit_file" => render_edit_file_tool_config,
"delete_path" => render_delete_path_tool_config,
"copy_path" => render_copy_path_tool_config,
"move_path" => render_move_path_tool_config,
"create_directory" => render_create_directory_tool_config,
"save_file" => render_save_file_tool_config,
@@ -214,19 +299,18 @@ fn get_tool_render_fn(
/// Renders an individual tool's permission configuration page
pub(crate) fn render_tool_config_page(
tool_id: &'static str,
_settings_window: &SettingsWindow,
tool: &ToolInfo,
settings_window: &SettingsWindow,
scroll_handle: &ScrollHandle,
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> AnyElement {
let tool = TOOLS.iter().find(|t| t.id == tool_id).unwrap();
let rules = get_tool_rules(tool_id, cx);
let rules = get_tool_rules(tool.id, cx);
let page_title = format!("{} Tool", tool.name);
let scroll_step = px(80.);
v_flex()
.id(format!("tool-config-page-{}", tool_id))
.id(format!("tool-config-page-{}", tool.id))
.on_action({
let scroll_handle = scroll_handle.clone();
move |_: &menu::SelectNext, window, cx| {
@@ -260,81 +344,92 @@ pub(crate) fn render_tool_config_page(
.color(Color::Muted),
),
)
.when(tool_id == "terminal", |this| {
.when(tool.id == TerminalTool::NAME, |this| {
this.child(render_hardcoded_security_banner(cx))
})
.child(render_verification_section(tool_id, window, cx))
.child(render_verification_section(tool.id, window, cx))
.when_some(
settings_window.regex_validation_error.clone(),
|this, error| {
this.child(
Banner::new()
.severity(Severity::Warning)
.child(Label::new(error).size(LabelSize::Small))
.action_slot(
Button::new("dismiss-regex-error", "Dismiss")
.style(ButtonStyle::Tinted(ui::TintColor::Warning))
.on_click(cx.listener(|this, _, _, cx| {
this.regex_validation_error = None;
cx.notify();
})),
),
)
},
)
.child(
v_flex()
.mt_6()
.min_w_0()
.w_full()
.gap_5()
.child(render_default_mode_section(tool_id, rules.default_mode, cx))
.child(render_default_mode_section(tool.id, rules.default_mode, cx))
.child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
.child(render_rule_section(
tool_id,
tool.id,
"Always Deny",
"If any of these regexes match, the tool action will be denied.",
RuleType::Deny,
ToolPermissionMode::Deny,
&rules.always_deny,
cx,
))
.child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
.child(render_rule_section(
tool_id,
tool.id,
"Always Allow",
"If any of these regexes match, the tool action will be approved—unless an Always Confirm or Always Deny regex matches.",
RuleType::Allow,
"If any of these regexes match, the action will be approved—unless an Always Confirm or Always Deny matches.",
ToolPermissionMode::Allow,
&rules.always_allow,
cx,
))
.child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
.child(render_rule_section(
tool_id,
tool.id,
"Always Confirm",
"If any of these regexes match, a confirmation will be shown unless an Always Deny regex matches.",
RuleType::Confirm,
ToolPermissionMode::Confirm,
&rules.always_confirm,
cx,
)),
))
.when(!rules.invalid_patterns.is_empty(), |this| {
this.child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
.child(render_invalid_patterns_section(
tool.id,
&rules.invalid_patterns,
cx,
))
}),
)
.into_any_element()
}
fn render_hardcoded_security_banner(cx: &mut Context<SettingsWindow>) -> AnyElement {
let pattern_labels = HARDCODED_SECURITY_RULES.terminal_deny.iter().map(|rule| {
h_flex()
.gap_1()
.child(
Icon::new(IconName::Dash)
.color(Color::Hidden)
.size(IconSize::Small),
)
.child(
Label::new(rule.pattern.clone())
.size(LabelSize::Small)
.color(Color::Muted)
.buffer_font(cx),
)
});
fn render_hardcoded_rules(smaller_font_size: bool, cx: &App) -> AnyElement {
div()
.map(|this| {
if smaller_font_size {
this.text_xs()
} else {
this.text_sm()
}
})
.text_color(cx.theme().colors().text_muted)
.child(render_inline_code_markdown(HARDCODED_RULES_DESCRIPTION, cx))
.into_any_element()
}
v_flex()
fn render_hardcoded_security_banner(cx: &mut Context<SettingsWindow>) -> AnyElement {
div()
.mt_3()
.child(
Banner::new().child(
v_flex()
.py_1()
.gap_1()
.child(
Label::new(
"The following patterns are always blocked and cannot be overridden:",
)
.size(LabelSize::Small),
)
.children(pattern_labels),
),
)
.child(Banner::new().child(render_hardcoded_rules(false, cx)))
.into_any_element()
}
@@ -350,7 +445,7 @@ fn render_verification_section(
let editor = window.use_keyed_state(input_id, cx, |window, cx| {
let mut editor = editor::Editor::single_line(window, cx);
editor.set_placeholder_text("Enter a rule to see how it applies…", window, cx);
editor.set_placeholder_text("Enter a tool input to test your rules…", window, cx);
let global_settings = ThemeSettings::get_global(cx);
editor.set_text_style_refinement(TextStyleRefinement {
@@ -375,8 +470,42 @@ fn render_verification_section(
(Some(decision), matches)
};
let default_mode = get_tool_rules(tool_id, cx).default_mode;
let is_hardcoded_denial = matches!(
&decision,
Some(ToolPermissionDecision::Deny(reason))
if reason.contains("built-in security rule")
);
let denial_reason = match &decision {
Some(ToolPermissionDecision::Deny(reason))
if !reason.is_empty() && !is_hardcoded_denial =>
{
Some(reason.clone())
}
_ => None,
};
let (authoritative_mode, patterns_agree) = match &decision {
Some(decision) => {
let authoritative = decision_to_mode(decision);
let implied = implied_mode_from_patterns(&matched_patterns, default_mode);
let agrees = authoritative == implied;
if !agrees {
log::error!(
"Tool permission verdict disagreement for '{}': \
engine={}, pattern_preview={}. \
Showing authoritative verdict only.",
tool_id,
mode_display_label(authoritative),
mode_display_label(implied),
);
}
(Some(authoritative), agrees)
}
None => (None, true),
};
let always_allow_description = "The Always Allow Tool Actions setting is enabled: all tools will be allowed regardless of these rules.";
let theme_colors = cx.theme().colors();
let color = cx.theme().colors();
v_flex()
.mt_3()
@@ -410,10 +539,10 @@ fn render_verification_section(
v_flex()
.p_2p5()
.gap_1p5()
.bg(theme_colors.surface_background.opacity(0.15))
.bg(color.surface_background.opacity(0.15))
.border_1()
.border_dashed()
.border_color(theme_colors.border_variant)
.border_color(color.border_variant)
.rounded_sm()
.child(
Label::new("Test Your Rules")
@@ -427,21 +556,48 @@ fn render_verification_section(
.px_2()
.rounded_md()
.border_1()
.border_color(theme_colors.border)
.bg(theme_colors.editor_background)
.border_color(color.border)
.bg(color.editor_background)
.track_focus(&focus_handle)
.child(editor),
)
.when(decision.is_some(), |this| {
if matched_patterns.is_empty() {
this.child(
Label::new("No regex matches, using the default action (confirm).")
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.child(render_matched_patterns(&matched_patterns, cx))
}
.when_some(authoritative_mode, |this, _| {
this.when(patterns_agree, |this| {
if matched_patterns.is_empty() {
this.child(
Label::new("No regex matches, using the default action (confirm).")
.size(LabelSize::Small)
.color(Color::Muted),
)
} else {
this.child(render_matched_patterns(&matched_patterns, cx))
}
})
.when(!patterns_agree, |this| {
if is_hardcoded_denial {
this.child(render_hardcoded_rules(true, cx))
} else if let Some(reason) = &denial_reason {
this.child(
Label::new(reason).size(LabelSize::XSmall),
)
} else {
this.child(
Label::new(
"Pattern preview differs from engine — showing authoritative result.",
)
.size(LabelSize::XSmall),
)
}
})
.when(is_hardcoded_denial && patterns_agree, |this| {
this.child(render_hardcoded_rules(true, cx))
})
.when_some(
denial_reason.filter(|_| patterns_agree && !is_hardcoded_denial),
|this, reason| {
this.child(Label::new(reason).size(LabelSize::XSmall))
},
)
}),
)
.into_any_element()
@@ -450,7 +606,7 @@ fn render_verification_section(
#[derive(Clone, Debug)]
struct MatchedPattern {
pattern: String,
rule_type: RuleType,
rule_type: ToolPermissionMode,
is_overridden: bool,
}
@@ -462,37 +618,62 @@ fn find_matched_patterns(tool_id: &str, input: &str, cx: &App) -> Vec<MatchedPat
};
let mut matched = Vec::new();
// For terminal commands, parse chained commands (&&, ||, ;) so the preview
// matches the real permission engine's behavior.
// When parsing fails (extract_commands returns None), the real engine
// ignores always_allow rules, so we track parse success to mirror that.
let (inputs_to_check, allow_enabled) = if tool_id == TerminalTool::NAME {
match extract_commands(input) {
Some(cmds) => (cmds, true),
None => (vec![input.to_string()], false),
}
} else {
(vec![input.to_string()], true)
};
let mut has_deny_match = false;
let mut has_confirm_match = false;
for rule in &rules.always_deny {
if rule.is_match(input) {
if inputs_to_check.iter().any(|cmd| rule.is_match(cmd)) {
has_deny_match = true;
matched.push(MatchedPattern {
pattern: rule.pattern.clone(),
rule_type: RuleType::Deny,
rule_type: ToolPermissionMode::Deny,
is_overridden: false,
});
}
}
for rule in &rules.always_confirm {
if rule.is_match(input) {
if inputs_to_check.iter().any(|cmd| rule.is_match(cmd)) {
has_confirm_match = true;
matched.push(MatchedPattern {
pattern: rule.pattern.clone(),
rule_type: RuleType::Confirm,
rule_type: ToolPermissionMode::Confirm,
is_overridden: has_deny_match,
});
}
}
// The real engine requires ALL commands to match at least one allow
// pattern for the overall verdict to be Allow. Compute that first,
// then show individual patterns with correct override status.
let all_commands_matched_allow = !inputs_to_check.is_empty()
&& inputs_to_check
.iter()
.all(|cmd| rules.always_allow.iter().any(|rule| rule.is_match(cmd)));
for rule in &rules.always_allow {
if rule.is_match(input) {
if inputs_to_check.iter().any(|cmd| rule.is_match(cmd)) {
matched.push(MatchedPattern {
pattern: rule.pattern.clone(),
rule_type: RuleType::Allow,
is_overridden: has_deny_match || has_confirm_match,
rule_type: ToolPermissionMode::Allow,
is_overridden: !allow_enabled
|| has_deny_match
|| has_confirm_match
|| !all_commands_matched_allow,
});
}
}
@@ -505,9 +686,9 @@ fn render_matched_patterns(patterns: &[MatchedPattern], cx: &App) -> AnyElement
.gap_1()
.children(patterns.iter().map(|pattern| {
let (type_label, color) = match pattern.rule_type {
RuleType::Deny => ("Always Deny", Color::Error),
RuleType::Confirm => ("Always Confirm", Color::Warning),
RuleType::Allow => ("Always Allow", Color::Success),
ToolPermissionMode::Deny => ("Always Deny", Color::Error),
ToolPermissionMode::Confirm => ("Always Confirm", Color::Warning),
ToolPermissionMode::Allow => ("Always Allow", Color::Success),
};
let type_color = if pattern.is_overridden {
@@ -558,11 +739,52 @@ fn evaluate_test_input(tool_id: &str, input: &str, cx: &App) -> ToolPermissionDe
)
}
fn decision_to_mode(decision: &ToolPermissionDecision) -> ToolPermissionMode {
match decision {
ToolPermissionDecision::Allow => ToolPermissionMode::Allow,
ToolPermissionDecision::Deny(_) => ToolPermissionMode::Deny,
ToolPermissionDecision::Confirm => ToolPermissionMode::Confirm,
}
}
fn implied_mode_from_patterns(
patterns: &[MatchedPattern],
default_mode: ToolPermissionMode,
) -> ToolPermissionMode {
let has_active_deny = patterns
.iter()
.any(|p| matches!(p.rule_type, ToolPermissionMode::Deny) && !p.is_overridden);
let has_active_confirm = patterns
.iter()
.any(|p| matches!(p.rule_type, ToolPermissionMode::Confirm) && !p.is_overridden);
let has_active_allow = patterns
.iter()
.any(|p| matches!(p.rule_type, ToolPermissionMode::Allow) && !p.is_overridden);
if has_active_deny {
ToolPermissionMode::Deny
} else if has_active_confirm {
ToolPermissionMode::Confirm
} else if has_active_allow {
ToolPermissionMode::Allow
} else {
default_mode
}
}
fn mode_display_label(mode: ToolPermissionMode) -> &'static str {
match mode {
ToolPermissionMode::Allow => "Allow",
ToolPermissionMode::Deny => "Deny",
ToolPermissionMode::Confirm => "Confirm",
}
}
fn render_rule_section(
tool_id: &'static str,
title: &'static str,
description: &'static str,
rule_type: RuleType,
rule_type: ToolPermissionMode,
patterns: &[String],
cx: &mut Context<SettingsWindow>,
) -> AnyElement {
@@ -604,6 +826,35 @@ fn render_rule_section(
.into_any_element()
}
fn render_invalid_patterns_section(
tool_id: &'static str,
invalid_patterns: &[String],
_cx: &mut Context<SettingsWindow>,
) -> AnyElement {
let section_id = format!("{}-invalid-section", tool_id);
v_flex()
.id(section_id)
.child(Label::new("Invalid Patterns").color(Color::Error))
.child(
Label::new("These patterns failed to compile as valid regexes. They will block the tool from running until fixed or removed.")
.size(LabelSize::Small)
.color(Color::Muted),
)
.child(
v_flex()
.mt_2()
.gap_1()
.children(invalid_patterns.iter().map(|description| {
Label::new(description.clone())
.size(LabelSize::Small)
.color(Color::Error)
.into_any_element()
})),
)
.into_any_element()
}
fn render_pattern_empty_state(cx: &mut Context<SettingsWindow>) -> AnyElement {
h_flex()
.p_2()
@@ -621,7 +872,7 @@ fn render_pattern_empty_state(cx: &mut Context<SettingsWindow>) -> AnyElement {
fn render_user_pattern_row(
tool_id: &'static str,
rule_type: RuleType,
rule_type: ToolPermissionMode,
index: usize,
pattern: String,
cx: &mut Context<SettingsWindow>,
@@ -667,7 +918,7 @@ fn render_user_pattern_row(
fn render_add_pattern_input(
tool_id: &'static str,
rule_type: RuleType,
rule_type: ToolPermissionMode,
_cx: &mut Context<SettingsWindow>,
) -> AnyElement {
let tool_id_owned = tool_id.to_string();
@@ -680,10 +931,15 @@ fn render_add_pattern_input(
.with_buffer_font()
.display_clear_button()
.display_confirm_button()
.clear_on_confirm()
.on_confirm(move |pattern, _window, cx| {
if let Some(pattern) = pattern {
if !pattern.trim().is_empty() {
save_pattern(&tool_id_owned, rule_type, pattern.trim().to_string(), cx);
let trimmed = pattern.trim().to_string();
if !trimmed.is_empty() {
if let Err(err) = regex::Regex::new(&trimmed) {
log::warn!("Invalid regex pattern '{}': {}", trimmed, err);
}
save_pattern(&tool_id_owned, rule_type, trimmed, cx);
}
}
})
@@ -746,18 +1002,12 @@ fn render_default_mode_section(
.into_any_element()
}
#[derive(Clone, Copy, PartialEq, Debug)]
pub(crate) enum RuleType {
Allow,
Deny,
Confirm,
}
struct ToolRulesView {
default_mode: ToolPermissionMode,
always_allow: Vec<String>,
always_deny: Vec<String>,
always_confirm: Vec<String>,
invalid_patterns: Vec<String>,
}
fn get_tool_rules(tool_name: &str, cx: &App) -> ToolRulesView {
@@ -783,17 +1033,23 @@ fn get_tool_rules(tool_name: &str, cx: &App) -> ToolRulesView {
.iter()
.map(|r| r.pattern.clone())
.collect(),
invalid_patterns: rules
.invalid_patterns
.iter()
.map(|p| format!("{} ({}): {}", p.pattern, p.rule_type, p.error))
.collect(),
},
None => ToolRulesView {
default_mode: ToolPermissionMode::Confirm,
always_allow: Vec::new(),
always_deny: Vec::new(),
always_confirm: Vec::new(),
invalid_patterns: Vec::new(),
},
}
}
fn save_pattern(tool_name: &str, rule_type: RuleType, pattern: String, cx: &mut App) {
fn save_pattern(tool_name: &str, rule_type: ToolPermissionMode, pattern: String, cx: &mut App) {
let tool_name = tool_name.to_string();
SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
@@ -813,9 +1069,9 @@ fn save_pattern(tool_name: &str, rule_type: RuleType, pattern: String, cx: &mut
};
let rules_list = match rule_type {
RuleType::Allow => tool_rules.always_allow.get_or_insert_default(),
RuleType::Deny => tool_rules.always_deny.get_or_insert_default(),
RuleType::Confirm => tool_rules.always_confirm.get_or_insert_default(),
ToolPermissionMode::Allow => tool_rules.always_allow.get_or_insert_default(),
ToolPermissionMode::Deny => tool_rules.always_deny.get_or_insert_default(),
ToolPermissionMode::Confirm => tool_rules.always_confirm.get_or_insert_default(),
};
if !rules_list.0.iter().any(|r| r.pattern == rule.pattern) {
@@ -826,7 +1082,7 @@ fn save_pattern(tool_name: &str, rule_type: RuleType, pattern: String, cx: &mut
fn update_pattern(
tool_name: &str,
rule_type: RuleType,
rule_type: ToolPermissionMode,
old_pattern: &str,
new_pattern: String,
cx: &mut App,
@@ -843,9 +1099,9 @@ fn update_pattern(
if let Some(tool_rules) = tool_permissions.tools.get_mut(tool_name.as_str()) {
let rules_list = match rule_type {
RuleType::Allow => &mut tool_rules.always_allow,
RuleType::Deny => &mut tool_rules.always_deny,
RuleType::Confirm => &mut tool_rules.always_confirm,
ToolPermissionMode::Allow => &mut tool_rules.always_allow,
ToolPermissionMode::Deny => &mut tool_rules.always_deny,
ToolPermissionMode::Confirm => &mut tool_rules.always_confirm,
};
if let Some(list) = rules_list {
@@ -857,7 +1113,7 @@ fn update_pattern(
});
}
fn delete_pattern(tool_name: &str, rule_type: RuleType, pattern: &str, cx: &mut App) {
fn delete_pattern(tool_name: &str, rule_type: ToolPermissionMode, pattern: &str, cx: &mut App) {
let tool_name = tool_name.to_string();
let pattern = pattern.to_string();
@@ -870,9 +1126,9 @@ fn delete_pattern(tool_name: &str, rule_type: RuleType, pattern: &str, cx: &mut
if let Some(tool_rules) = tool_permissions.tools.get_mut(tool_name.as_str()) {
let rules_list = match rule_type {
RuleType::Allow => &mut tool_rules.always_allow,
RuleType::Deny => &mut tool_rules.always_deny,
RuleType::Confirm => &mut tool_rules.always_confirm,
ToolPermissionMode::Allow => &mut tool_rules.always_allow,
ToolPermissionMode::Deny => &mut tool_rules.always_deny,
ToolPermissionMode::Confirm => &mut tool_rules.always_confirm,
};
if let Some(list) = rules_list {
@@ -899,7 +1155,6 @@ fn set_default_mode(tool_name: &str, mode: ToolPermissionMode, cx: &mut App) {
});
}
// Macro to generate render functions for each tool
macro_rules! tool_config_page_fn {
($fn_name:ident, $tool_id:literal) => {
pub fn $fn_name(
@@ -908,7 +1163,8 @@ macro_rules! tool_config_page_fn {
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) -> AnyElement {
render_tool_config_page($tool_id, settings_window, scroll_handle, window, cx)
const INDEX: usize = tool_index($tool_id);
render_tool_config_page(&TOOLS[INDEX], settings_window, scroll_handle, window, cx)
}
};
}
@@ -916,8 +1172,61 @@ macro_rules! tool_config_page_fn {
tool_config_page_fn!(render_terminal_tool_config, "terminal");
tool_config_page_fn!(render_edit_file_tool_config, "edit_file");
tool_config_page_fn!(render_delete_path_tool_config, "delete_path");
tool_config_page_fn!(render_copy_path_tool_config, "copy_path");
tool_config_page_fn!(render_move_path_tool_config, "move_path");
tool_config_page_fn!(render_create_directory_tool_config, "create_directory");
tool_config_page_fn!(render_save_file_tool_config, "save_file");
tool_config_page_fn!(render_fetch_tool_config, "fetch");
tool_config_page_fn!(render_web_search_tool_config, "web_search");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_all_tools_are_in_tool_info_or_excluded() {
const EXCLUDED_TOOLS: &[&str] = &[
"diagnostics",
"find_path",
"grep",
"list_directory",
"now",
"open",
"read_file",
"restore_file_from_disk",
"thinking",
"streaming_edit_file",
"subagent",
];
let tool_info_ids: Vec<&str> = TOOLS.iter().map(|t| t.id).collect();
for tool_name in agent::ALL_TOOL_NAMES {
if EXCLUDED_TOOLS.contains(tool_name) {
assert!(
!tool_info_ids.contains(tool_name),
"Tool '{}' is in both EXCLUDED_TOOLS and TOOLS — pick one.",
tool_name,
);
continue;
}
assert!(
tool_info_ids.contains(tool_name),
"Tool '{}' is in ALL_TOOL_NAMES but has no entry in TOOLS and \
is not in EXCLUDED_TOOLS. Either add a ToolInfo entry (if the \
tool has permission checks) or add it to EXCLUDED_TOOLS with \
a comment explaining why.",
tool_name,
);
}
for tool_id in &tool_info_ids {
assert!(
agent::ALL_TOOL_NAMES.contains(tool_id),
"TOOLS contains '{}' but it is not in ALL_TOOL_NAMES. \
Is this a valid built-in tool?",
tool_id,
);
}
}
}
+6
View File
@@ -744,6 +744,7 @@ pub struct SettingsWindow {
search_index: Option<Arc<SearchIndex>>,
list_state: ListState,
shown_errors: HashSet<String>,
pub(crate) regex_validation_error: Option<String>,
}
struct SearchIndex {
@@ -1661,6 +1662,7 @@ impl SettingsWindow {
.tab_stop(false),
search_index: None,
shown_errors: HashSet::default(),
regex_validation_error: None,
list_state,
};
@@ -3512,6 +3514,7 @@ impl SettingsWindow {
window: &mut Window,
cx: &mut Context<SettingsWindow>,
) {
self.regex_validation_error = None;
let sub_page_link = SubPageLink {
title: title.into(),
r#type: SubPageType::default(),
@@ -3596,6 +3599,7 @@ impl SettingsWindow {
}
fn pop_sub_page(&mut self, window: &mut Window, cx: &mut Context<SettingsWindow>) {
self.regex_validation_error = None;
self.sub_page_stack.pop();
self.content_focus_handle.focus_handle(cx).focus(window, cx);
cx.notify();
@@ -4350,6 +4354,7 @@ pub mod test {
search_index: None,
list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
shown_errors: HashSet::default(),
regex_validation_error: None,
}
}
}
@@ -4474,6 +4479,7 @@ pub mod test {
search_index: None,
list_state: ListState::new(0, gpui::ListAlignment::Top, px(0.0)),
shown_errors: HashSet::default(),
regex_validation_error: None,
};
settings_window.build_filter_table();
+1
View File
@@ -67,6 +67,7 @@ impl ParentElement for Banner {
impl RenderOnce for Banner {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let banner = h_flex()
.min_w_0()
.py_0p5()
.gap_1p5()
.when(self.wrap_content, |this| this.flex_wrap())