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: "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 .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 "web_search" => 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, ¤t_text, cx);
484 let decision = evaluate_test_input(tool_id, ¤t_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, "web_search");
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}