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