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