Granular Tool Permission Buttons (#46284)

Adds buttons for confirming (and optionally persisting) granular
permissions on tool calls:

<img width="688" height="302" alt="Screenshot 2026-01-14 at 1 58 40 PM"
src="https://github.com/user-attachments/assets/3228cc39-efd5-4a73-989f-ddb28969847f"
/>
<img width="690" height="282" alt="Screenshot 2026-01-14 at 1 58 31 PM"
src="https://github.com/user-attachments/assets/8367a888-5728-4877-a502-1ff20ac929ec"
/>

## Features

### Tool Permission Settings
- **Per-tool rules** in `agent.tool_permissions.tools.<tool_name>`:
  - `default_mode`: `"allow"`, `"deny"`, or `"confirm"` (default)
  - `always_allow`: Array of regex patterns to auto-approve
  - `always_deny`: Array of regex patterns to block
  - `always_confirm`: Array of regex patterns requiring confirmation
- **Supported tools**: `terminal`, `edit_file`, `delete_path`,
`move_path`, `create_directory`, `save_file`, `copy_path`, `fetch`,
`web_search`
- **MCP tool support**: Third-party tools from context servers with
`mcp:<server>:<tool>` naming

### Smart Permission Buttons
When a tool requires confirmation, the dialog shows contextual buttons:
- **"Always allow \`<tool>\`"** - Sets `default_mode = "allow"` for the
tool
- **"Always allow \`<pattern>\`"** - Adds a pattern to `always_allow`:
  - Terminal: Command name (e.g., `cargo`, `npm`, `git`)
  - File tools: Parent directory path
  - URL tools: Domain name
- **"Allow"** / **"Deny"** - One-time decision

### Pattern Extraction
Automatically extracts meaningful patterns from tool inputs:
- Terminal commands → `^cargo\s`, `^npm\s`, etc.
- File paths → `^/Users/alice/project/src/`
- URLs → `^https?://github\.com`

### Invalid Pattern Handling
- Malformed regex patterns are detected at settings load time
- Tools with invalid patterns show an error and block execution
- Clear error messages identify which pattern failed

## Changes

### New Files
- `crates/agent/src/tool_permissions.rs` - Permission evaluation logic
- `crates/agent/src/pattern_extraction.rs` - Pattern extraction
functions

### Modified Files
- `crates/agent_settings/src/agent_settings.rs` - `ToolPermissions`,
`ToolRules` structs
- `crates/settings/src/settings_content/agent.rs` - Settings schema and
serialization
- `crates/settings/src/settings_file.rs` - Helper methods for modifying
settings
- `crates/agent/src/thread.rs` - `authorize_with_context()`,
`authorize_third_party_tool()` methods
- `crates/agent/src/tools/*.rs` - Updated all granular tools to use
permission system
- `crates/agent/src/tools/context_server_registry.rs` - MCP tool
authorization
- `crates/agent_ui/src/acp/thread_view.rs` - Permission button UI,
removed Keep/Reject buttons

## Example Configuration

```json
{
  "agent": {
    "tool_permissions": {
      "tools": {
        "terminal": {
          "default_mode": "confirm",
          "always_allow": ["^cargo\\s", "^npm\\s", "^git\\s"],
          "always_deny": ["^rm\\s+-rf", "^sudo\\s"]
        },
        "edit_file": {
          "default_mode": "allow",
          "always_deny": ["^\\.env", "^/etc/"]
        },
        "mcp:filesystem:write_file": {
          "default_mode": "confirm"
        }
      }
    }
  }
}
```

Release Notes:

- You can now set per-tool permissions when confirming/denying tool use

---------

Co-authored-by: Amp <amp@ampcode.com>
Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
Co-authored-by: Michael Benfield <mbenfield@zed.dev>
This commit is contained in:
authored and GitHub committed 2026-01-14 19:39:04 +00:00
1 parent be110b3836
commit dd4699dc72
29 files changed
+2599 -486

No files matched your search

Generated
+1
View File
@@ -217,6 +217,7 @@ dependencies = [
"tree-sitter-rust",
"ui",
"unindent",
"url",
"util",
"uuid",
"watch",
+1 -1
View File
@@ -259,7 +259,7 @@
"super-ctrl-b": "agent::ToggleBurnMode",
"alt-enter": "agent::ContinueWithBurnMode",
"ctrl-y": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"ctrl-alt-z": "agent::RejectOnce",
},
},
+1 -1
View File
@@ -300,7 +300,7 @@
"cmd-shift-enter": "agent::ContinueThread",
"alt-enter": "agent::ContinueWithBurnMode",
"cmd-y": "agent::AllowOnce",
"cmd-alt-y": "agent::AllowAlways",
"cmd-alt-a": "agent::OpenPermissionDropdown",
"cmd-alt-z": "agent::RejectOnce",
},
},
+1 -1
View File
@@ -260,7 +260,7 @@
"super-ctrl-b": "agent::ToggleBurnMode",
"alt-enter": "agent::ContinueWithBurnMode",
"shift-alt-a": "agent::AllowOnce",
"ctrl-alt-y": "agent::AllowAlways",
"ctrl-alt-a": "agent::OpenPermissionDropdown",
"shift-alt-z": "agent::RejectOnce",
},
},
-22
View File
@@ -997,22 +997,6 @@
// Privileged commands
{ "pattern": "sudo\\s" },
],
"always_allow": [
// Build and test commands
{ "pattern": "^cargo\\s+(build|test|check|clippy|run)" },
{ "pattern": "^npm\\s+(test|run|install)" },
{ "pattern": "^pnpm\\s+(test|run|install)" },
{ "pattern": "^yarn\\s+(test|run|install)" },
// Safe read-only commands
{ "pattern": "^ls(\\s|$)" },
{ "pattern": "^cat\\s" },
{ "pattern": "^head\\s" },
{ "pattern": "^tail\\s" },
{ "pattern": "^grep\\s" },
{ "pattern": "^find\\s" },
// Safe git commands
{ "pattern": "^git\\s+(status|log|diff|branch|show)" },
],
},
"edit_file": {
"default_mode": "confirm",
@@ -1048,12 +1032,6 @@
},
"fetch": {
"default_mode": "confirm",
"always_allow": [
// Common documentation sites
{ "pattern": "^https://(docs\\.|api\\.)?github\\.com" },
{ "pattern": "^https://docs\\.rs" },
{ "pattern": "^https://crates\\.io" },
],
},
},
},
+1
View File
@@ -66,6 +66,7 @@ telemetry.workspace = true
text.workspace = true
thiserror.workspace = true
ui.workspace = true
url.workspace = true
util.workspace = true
uuid.workspace = true
watch.workspace = true
+3
View File
@@ -3,6 +3,7 @@ mod edit_agent;
mod legacy_thread;
mod native_agent_server;
pub mod outline;
mod pattern_extraction;
mod templates;
#[cfg(test)]
mod tests;
@@ -14,6 +15,7 @@ mod tools;
use context_server::ContextServerId;
pub use db::*;
pub use native_agent_server::NativeAgentServer;
pub use pattern_extraction::*;
pub use templates::*;
pub use thread::*;
pub use thread_store::*;
@@ -994,6 +996,7 @@ impl NativeAgentConnection {
tool_call,
options,
response,
context: _,
}) => {
let outcome_task = acp_thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(
+166
View File
@@ -0,0 +1,166 @@
use url::Url;
/// Extracts a regex pattern from a terminal command based on the first token (command name).
///
/// Returns `None` for commands starting with `./`, `/`, or other path-like prefixes.
/// This is a deliberate security decision: we only allow pattern-based "always allow"
/// rules for well-known command names (like `cargo`, `npm`, `git`), not for arbitrary
/// scripts or absolute paths which could be manipulated by an attacker.
pub fn extract_terminal_pattern(command: &str) -> Option<String> {
let first_token = command.split_whitespace().next()?;
// Only allow alphanumeric commands with hyphens/underscores.
// Reject paths like "./script.sh" or "/usr/bin/python" to prevent
// users from accidentally allowing arbitrary script execution.
if first_token
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
Some(format!("^{}\\s", regex::escape(first_token)))
} else {
None
}
}
pub fn extract_terminal_pattern_display(command: &str) -> Option<String> {
let first_token = command.split_whitespace().next()?;
if first_token
.chars()
.all(|c| c.is_alphanumeric() || c == '-' || c == '_')
{
Some(first_token.to_string())
} else {
None
}
}
pub fn extract_path_pattern(path: &str) -> Option<String> {
let parent = std::path::Path::new(path).parent()?;
let parent_str = parent.to_str()?;
if parent_str.is_empty() || parent_str == "/" {
return None;
}
Some(format!("^{}/", regex::escape(parent_str)))
}
pub fn extract_path_pattern_display(path: &str) -> Option<String> {
let parent = std::path::Path::new(path).parent()?;
let parent_str = parent.to_str()?;
if parent_str.is_empty() || parent_str == "/" {
return None;
}
Some(format!("{}/", parent_str))
}
pub fn extract_url_pattern(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
let domain = parsed.host_str()?;
Some(format!("^https?://{}", regex::escape(domain)))
}
pub fn extract_url_pattern_display(url: &str) -> Option<String> {
let parsed = Url::parse(url).ok()?;
let domain = parsed.host_str()?;
Some(domain.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_terminal_pattern() {
assert_eq!(
extract_terminal_pattern("cargo build --release"),
Some("^cargo\\s".to_string())
);
assert_eq!(
extract_terminal_pattern("npm install"),
Some("^npm\\s".to_string())
);
assert_eq!(
extract_terminal_pattern("git-lfs pull"),
Some("^git\\-lfs\\s".to_string())
);
assert_eq!(
extract_terminal_pattern("my_script arg"),
Some("^my_script\\s".to_string())
);
assert_eq!(extract_terminal_pattern("./script.sh arg"), None);
assert_eq!(extract_terminal_pattern("/usr/bin/python arg"), None);
}
#[test]
fn test_extract_terminal_pattern_display() {
assert_eq!(
extract_terminal_pattern_display("cargo build --release"),
Some("cargo".to_string())
);
assert_eq!(
extract_terminal_pattern_display("npm install"),
Some("npm".to_string())
);
}
#[test]
fn test_extract_path_pattern() {
assert_eq!(
extract_path_pattern("/Users/alice/project/src/main.rs"),
Some("^/Users/alice/project/src/".to_string())
);
assert_eq!(
extract_path_pattern("src/lib.rs"),
Some("^src/".to_string())
);
assert_eq!(extract_path_pattern("file.txt"), None);
assert_eq!(extract_path_pattern("/file.txt"), None);
}
#[test]
fn test_extract_path_pattern_display() {
assert_eq!(
extract_path_pattern_display("/Users/alice/project/src/main.rs"),
Some("/Users/alice/project/src/".to_string())
);
assert_eq!(
extract_path_pattern_display("src/lib.rs"),
Some("src/".to_string())
);
}
#[test]
fn test_extract_url_pattern() {
assert_eq!(
extract_url_pattern("https://github.com/user/repo"),
Some("^https?://github\\.com".to_string())
);
assert_eq!(
extract_url_pattern("http://example.com/path?query=1"),
Some("^https?://example\\.com".to_string())
);
assert_eq!(extract_url_pattern("not a url"), None);
}
#[test]
fn test_extract_url_pattern_display() {
assert_eq!(
extract_url_pattern_display("https://github.com/user/repo"),
Some("github.com".to_string())
);
assert_eq!(
extract_url_pattern_display("http://api.example.com/v1/users"),
Some("api.example.com".to_string())
);
}
#[test]
fn test_special_chars_are_escaped() {
assert_eq!(
extract_path_pattern("/path/with (parens)/file.txt"),
Some("^/path/with \\(parens\\)/".to_string())
);
assert_eq!(
extract_url_pattern("https://test.example.com/path"),
Some("^https?://test\\.example\\.com".to_string())
);
}
}
+11 -7
View File
@@ -772,17 +772,17 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
let tool_call_auth_1 = next_tool_call_authorization(&mut events).await;
let tool_call_auth_2 = next_tool_call_authorization(&mut events).await;
// Approve the first
// Approve the first - send "allow" option_id (UI transforms "once" to "allow")
tool_call_auth_1
.response
.send(tool_call_auth_1.options[1].option_id.clone())
.send(acp::PermissionOptionId::new("allow"))
.unwrap();
cx.run_until_parked();
// Reject the second
// Reject the second - send "deny" option_id directly since Deny is now a button
tool_call_auth_2
.response
.send(tool_call_auth_1.options[2].option_id.clone())
.send(acp::PermissionOptionId::new("deny"))
.unwrap();
cx.run_until_parked();
@@ -821,11 +821,14 @@ async fn test_tool_authorization(cx: &mut TestAppContext) {
));
fake_model.end_last_completion_stream();
// Respond by always allowing tools.
// Respond by always allowing tools - send transformed option_id
// (UI transforms "always:tool_requiring_permission" to "always_allow:tool_requiring_permission")
let tool_call_auth_3 = next_tool_call_authorization(&mut events).await;
tool_call_auth_3
.response
.send(tool_call_auth_3.options[0].option_id.clone())
.send(acp::PermissionOptionId::new(
"always_allow:tool_requiring_permission",
))
.unwrap();
cx.run_until_parked();
let completion = fake_model.pending_completions().pop().unwrap();
@@ -1139,12 +1142,13 @@ async fn next_tool_call_authorization(
.iter()
.map(|o| o.kind)
.collect::<Vec<_>>();
// Only 2 options now: AllowAlways (for tool) and AllowOnce (granularity only)
// Deny is handled by the UI buttons, not as a separate option
assert_eq!(
permission_kinds,
vec![
acp::PermissionOptionKind::AllowAlways,
acp::PermissionOptionKind::AllowOnce,
acp::PermissionOptionKind::RejectOnce,
]
);
return tool_call_authorization;
+21 -2
View File
@@ -1,4 +1,5 @@
use super::*;
use agent_settings::AgentSettings;
use anyhow::Result;
use gpui::{App, SharedString, Task};
use std::future;
@@ -124,9 +125,27 @@ impl AgentTool for ToolRequiringPermission {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
let authorize = event_stream.authorize("Authorize?", cx);
let settings = AgentSettings::get_global(cx);
let decision = decide_permission_from_settings(Self::name(), "", settings);
let authorize = match decision {
ToolPermissionDecision::Allow => None,
ToolPermissionDecision::Deny(reason) => {
return Task::ready(Err(anyhow::anyhow!("{}", reason)));
}
ToolPermissionDecision::Confirm => {
let context = crate::ToolPermissionContext {
tool_name: "tool_requiring_permission".to_string(),
input_value: String::new(),
};
Some(event_stream.authorize("Authorize?", context, cx))
}
};
cx.foreground_executor().spawn(async move {
authorize.await?;
if let Some(authorize) = authorize {
authorize.await?;
}
Ok("Allowed".to_string())
})
}
+227 -24
View File
@@ -3,7 +3,8 @@ use crate::{
DeletePathTool, DiagnosticsTool, EditFileTool, FetchTool, FindPathTool, GrepTool,
ListDirectoryTool, MovePathTool, NowTool, OpenTool, ProjectSnapshot, ReadFileTool,
RestoreFileFromDiskTool, SaveFileTool, SubagentTool, SystemPromptTemplate, Template, Templates,
TerminalTool, ThinkingTool, WebSearchTool,
TerminalTool, ThinkingTool, ToolPermissionDecision, WebSearchTool,
decide_permission_from_settings,
};
use acp_thread::{MentionUri, UserMessageId};
use action_log::ActionLog;
@@ -42,7 +43,7 @@ use project::Project;
use prompt_store::ProjectContext;
use schemars::{JsonSchema, Schema};
use serde::{Deserialize, Serialize};
use settings::{LanguageModelSelection, Settings, update_settings_file};
use settings::{LanguageModelSelection, Settings, ToolPermissionMode, update_settings_file};
use smol::stream::StreamExt;
use std::{
collections::BTreeMap,
@@ -593,11 +594,81 @@ pub struct NewTerminal {
pub response: oneshot::Sender<Result<Entity<acp_thread::Terminal>>>,
}
#[derive(Debug, Clone)]
pub struct ToolPermissionContext {
pub tool_name: String,
pub input_value: String,
}
impl ToolPermissionContext {
pub fn new(tool_name: impl Into<String>, input_value: impl Into<String>) -> Self {
Self {
tool_name: tool_name.into(),
input_value: input_value.into(),
}
}
/// Builds the permission options for this tool context.
///
/// This is the canonical source for permission option generation.
/// Tests should use this function rather than manually constructing options.
pub fn build_permission_options(&self) -> Vec<acp::PermissionOption> {
use crate::pattern_extraction::*;
let tool_name = &self.tool_name;
let input_value = &self.input_value;
let (pattern, pattern_display) = match tool_name.as_str() {
"terminal" => (
extract_terminal_pattern(input_value),
extract_terminal_pattern_display(input_value),
),
"edit_file" | "delete_path" | "move_path" | "create_directory" | "save_file" => (
extract_path_pattern(input_value),
extract_path_pattern_display(input_value),
),
"fetch" => (
extract_url_pattern(input_value),
extract_url_pattern_display(input_value),
),
_ => (None, None),
};
let mut options = vec![acp::PermissionOption::new(
acp::PermissionOptionId::new(format!("always:{}", tool_name)),
format!("Always for {}", tool_name.replace('_', " ")),
acp::PermissionOptionKind::AllowAlways,
)];
if let (Some(pattern), Some(display)) = (pattern, pattern_display) {
let button_text = match tool_name.as_str() {
"terminal" => format!("Always for `{}` commands", display),
"fetch" => format!("Always for `{}`", display),
_ => format!("Always for `{}`", display),
};
options.push(acp::PermissionOption::new(
acp::PermissionOptionId::new(format!("always_pattern:{}:{}", tool_name, pattern)),
button_text,
acp::PermissionOptionKind::AllowAlways,
));
}
options.push(acp::PermissionOption::new(
acp::PermissionOptionId::new("once"),
"Only this time",
acp::PermissionOptionKind::AllowOnce,
));
options
}
}
#[derive(Debug)]
pub struct ToolCallAuthorization {
pub tool_call: acp::ToolCallUpdate,
pub options: Vec<acp::PermissionOption>,
pub response: oneshot::Sender<acp::PermissionOptionId>,
pub context: Option<ToolPermissionContext>,
}
#[derive(Debug, thiserror::Error)]
@@ -2883,19 +2954,32 @@ impl ToolCallEventStream {
.ok();
}
pub fn authorize(&self, title: impl Into<String>, cx: &mut App) -> Task<Result<()>> {
if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
return Task::ready(Ok(()));
/// Authorize a third-party tool (e.g., MCP tool from a context server).
///
/// Unlike built-in tools, third-party tools don't support pattern-based permissions.
/// They only support `default_mode` (allow/deny/confirm) per tool.
///
/// Shows 3 buttons:
/// - "Always allow <display_name> MCP tool" → sets `tools.<tool_id>.default_mode = "allow"`
/// - "Allow" → approve once
/// - "Deny" → reject once
pub fn authorize_third_party_tool(
&self,
title: impl Into<String>,
tool_id: String,
display_name: String,
cx: &mut App,
) -> Task<Result<()>> {
let settings = agent_settings::AgentSettings::get_global(cx);
let decision = decide_permission_from_settings(&tool_id, "", &settings);
match decision {
ToolPermissionDecision::Allow => return Task::ready(Ok(())),
ToolPermissionDecision::Deny(reason) => return Task::ready(Err(anyhow!(reason))),
ToolPermissionDecision::Confirm => {}
}
self.authorize_required(title, cx)
}
/// Like `authorize`, but always prompts for confirmation regardless of
/// the `always_allow_tool_actions` setting. Use this when tool-specific
/// permission rules (like `always_confirm` patterns) have already determined
/// that confirmation is required.
pub fn authorize_required(&self, title: impl Into<String>, cx: &mut App) -> Task<Result<()>> {
let (response_tx, response_rx) = oneshot::channel();
self.stream
.0
@@ -2907,13 +2991,13 @@ impl ToolCallEventStream {
),
options: vec![
acp::PermissionOption::new(
acp::PermissionOptionId::new("always_allow"),
"Always Allow",
acp::PermissionOptionId::new(format!("always_allow_mcp:{}", tool_id)),
format!("Always allow {} MCP tool", display_name),
acp::PermissionOptionKind::AllowAlways,
),
acp::PermissionOption::new(
acp::PermissionOptionId::new("allow"),
"Allow",
"Allow once",
acp::PermissionOptionKind::AllowOnce,
),
acp::PermissionOption::new(
@@ -2923,27 +3007,146 @@ impl ToolCallEventStream {
),
],
response: response_tx,
context: None,
},
)))
.ok();
let fs = self.fs.clone();
cx.spawn(async move |cx| match response_rx.await?.0.as_ref() {
"always_allow" => {
cx.spawn(async move |cx| {
let response_str = response_rx.await?.0.to_string();
if response_str == format!("always_allow_mcp:{}", tool_id) {
if let Some(fs) = fs.clone() {
cx.update(|cx| {
update_settings_file(fs, cx, |settings, _| {
update_settings_file(fs, cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.set_always_allow_tool_actions(true);
.set_tool_default_mode(&tool_id, ToolPermissionMode::Allow);
});
});
}
Ok(())
return Ok(());
}
"allow" => Ok(()),
_ => Err(anyhow!("Permission to run tool denied by user")),
if response_str == "allow" {
return Ok(());
}
Err(anyhow!("Permission to run tool denied by user"))
})
}
pub fn authorize(
&self,
title: impl Into<String>,
context: ToolPermissionContext,
cx: &mut App,
) -> Task<Result<()>> {
use settings::ToolPermissionMode;
let options = context.build_permission_options();
let (response_tx, response_rx) = oneshot::channel();
self.stream
.0
.unbounded_send(Ok(ThreadEvent::ToolCallAuthorization(
ToolCallAuthorization {
tool_call: acp::ToolCallUpdate::new(
self.tool_use_id.to_string(),
acp::ToolCallUpdateFields::new().title(title.into()),
),
options,
response: response_tx,
context: Some(context),
},
)))
.ok();
let fs = self.fs.clone();
cx.spawn(async move |cx| {
let response_str = response_rx.await?.0.to_string();
// Handle "always allow tool" - e.g., "always_allow:terminal"
if let Some(tool) = response_str.strip_prefix("always_allow:") {
if let Some(fs) = fs.clone() {
let tool = tool.to_string();
cx.update(|cx| {
update_settings_file(fs, cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.set_tool_default_mode(&tool, ToolPermissionMode::Allow);
});
});
}
return Ok(());
}
// Handle "always deny tool" - e.g., "always_deny:terminal"
if let Some(tool) = response_str.strip_prefix("always_deny:") {
if let Some(fs) = fs.clone() {
let tool = tool.to_string();
cx.update(|cx| {
update_settings_file(fs, cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.set_tool_default_mode(&tool, ToolPermissionMode::Deny);
});
});
}
return Err(anyhow!("Permission to run tool denied by user"));
}
// Handle "always allow pattern" - e.g., "always_allow_pattern:terminal:^cargo\s"
if response_str.starts_with("always_allow_pattern:") {
let parts: Vec<&str> = response_str.splitn(3, ':').collect();
if parts.len() == 3 {
let pattern_tool_name = parts[1].to_string();
let pattern = parts[2].to_string();
if let Some(fs) = fs.clone() {
cx.update(|cx| {
update_settings_file(fs, cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.add_tool_allow_pattern(&pattern_tool_name, pattern);
});
});
}
}
return Ok(());
}
// Handle "always deny pattern" - e.g., "always_deny_pattern:terminal:^cargo\s"
if response_str.starts_with("always_deny_pattern:") {
let parts: Vec<&str> = response_str.splitn(3, ':').collect();
if parts.len() == 3 {
let pattern_tool_name = parts[1].to_string();
let pattern = parts[2].to_string();
if let Some(fs) = fs.clone() {
cx.update(|cx| {
update_settings_file(fs, cx, move |settings, _| {
settings
.agent
.get_or_insert_default()
.add_tool_deny_pattern(&pattern_tool_name, pattern);
});
});
}
}
return Err(anyhow!("Permission to run tool denied by user"));
}
// Handle simple "allow" (allow once)
if response_str == "allow" {
return Ok(());
}
// Handle simple "deny" (deny once)
Err(anyhow!("Permission to run tool denied by user"))
})
}
}
+427 -350
View File
@@ -130,135 +130,341 @@ mod tests {
use agent_settings::{CompiledRegex, InvalidRegexPattern, ToolRules};
use std::sync::Arc;
fn empty_permissions() -> ToolPermissions {
ToolPermissions {
tools: collections::HashMap::default(),
struct PermTest {
tool: &'static str,
input: &'static str,
mode: ToolPermissionMode,
allow: Vec<&'static str>,
deny: Vec<&'static str>,
confirm: Vec<&'static str>,
global: bool,
}
impl PermTest {
fn new(input: &'static str) -> Self {
Self {
tool: "terminal",
input,
mode: ToolPermissionMode::Confirm,
allow: vec![],
deny: vec![],
confirm: vec![],
global: false,
}
}
fn tool(mut self, t: &'static str) -> Self {
self.tool = t;
self
}
fn mode(mut self, m: ToolPermissionMode) -> Self {
self.mode = m;
self
}
fn allow(mut self, p: &[&'static str]) -> Self {
self.allow = p.to_vec();
self
}
fn deny(mut self, p: &[&'static str]) -> Self {
self.deny = p.to_vec();
self
}
fn confirm(mut self, p: &[&'static str]) -> Self {
self.confirm = p.to_vec();
self
}
fn global(mut self, g: bool) -> Self {
self.global = g;
self
}
fn is_allow(self) {
assert_eq!(
self.run(),
ToolPermissionDecision::Allow,
"expected Allow for '{}'",
self.input
);
}
fn is_deny(self) {
assert!(
matches!(self.run(), ToolPermissionDecision::Deny(_)),
"expected Deny for '{}'",
self.input
);
}
fn is_confirm(self) {
assert_eq!(
self.run(),
ToolPermissionDecision::Confirm,
"expected Confirm for '{}'",
self.input
);
}
fn run(&self) -> ToolPermissionDecision {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from(self.tool),
ToolRules {
default_mode: self.mode,
always_allow: self
.allow
.iter()
.filter_map(|p| CompiledRegex::new(p, false))
.collect(),
always_deny: self
.deny
.iter()
.filter_map(|p| CompiledRegex::new(p, false))
.collect(),
always_confirm: self
.confirm
.iter()
.filter_map(|p| CompiledRegex::new(p, false))
.collect(),
invalid_patterns: vec![],
},
);
decide_permission(
self.tool,
self.input,
&ToolPermissions { tools },
self.global,
)
}
}
fn terminal_rules_with_deny(patterns: &[&str]) -> ToolPermissions {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Confirm,
always_allow: vec![],
always_deny: patterns
.iter()
.filter_map(|p| CompiledRegex::new(p, false))
.collect(),
always_confirm: vec![],
invalid_patterns: vec![],
},
);
ToolPermissions { tools }
fn t(input: &'static str) -> PermTest {
PermTest::new(input)
}
fn terminal_rules_with_allow(patterns: &[&str]) -> ToolPermissions {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Confirm,
always_allow: patterns
.iter()
.filter_map(|p| CompiledRegex::new(p, false))
.collect(),
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
fn no_rules(input: &str, global: bool) -> ToolPermissionDecision {
decide_permission(
"terminal",
input,
&ToolPermissions {
tools: collections::HashMap::default(),
},
);
ToolPermissions { tools }
global,
)
}
// allow pattern matches
#[test]
fn test_deny_takes_precedence_over_allow() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![CompiledRegex::new("dangerous", false).unwrap()],
always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
fn allow_exact_match() {
t("cargo test").allow(&["^cargo\\s"]).is_allow();
}
#[test]
fn allow_with_args() {
t("cargo build --release").allow(&["^cargo\\s"]).is_allow();
}
#[test]
fn allow_one_of_many() {
t("npm install").allow(&["^cargo\\s", "^npm\\s"]).is_allow();
}
#[test]
fn allow_middle_pattern() {
t("run cargo now").allow(&["cargo"]).is_allow();
}
#[test]
fn allow_anchor_prevents_middle() {
t("run cargo now").allow(&["^cargo"]).is_confirm();
}
// allow pattern doesn't match -> falls through
#[test]
fn test_deny_takes_precedence_over_confirm() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![],
always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
always_confirm: vec![CompiledRegex::new("dangerous", false).unwrap()],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
fn allow_no_match_confirms() {
t("python x.py").allow(&["^cargo\\s"]).is_confirm();
}
#[test]
fn allow_no_match_global_allows() {
t("python x.py")
.allow(&["^cargo\\s"])
.global(true)
.is_allow();
}
// deny pattern matches
#[test]
fn test_confirm_takes_precedence_over_allow() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![CompiledRegex::new("risky", false).unwrap()],
always_deny: vec![],
always_confirm: vec![CompiledRegex::new("risky", false).unwrap()],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "do risky thing", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Confirm);
fn deny_blocks() {
t("rm -rf /").deny(&["rm\\s+-rf"]).is_deny();
}
#[test]
fn deny_blocks_with_global() {
t("rm -rf /").deny(&["rm\\s+-rf"]).global(true).is_deny();
}
#[test]
fn deny_blocks_with_mode_allow() {
t("rm -rf /")
.deny(&["rm\\s+-rf"])
.mode(ToolPermissionMode::Allow)
.is_deny();
}
#[test]
fn deny_middle_match() {
t("echo rm -rf x").deny(&["rm\\s+-rf"]).is_deny();
}
#[test]
fn deny_no_match_allows() {
t("ls -la").deny(&["rm\\s+-rf"]).global(true).is_allow();
}
// confirm pattern matches
#[test]
fn test_no_tool_rules_uses_global_setting() {
let permissions = empty_permissions();
let decision = decide_permission("terminal", "any command", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Confirm);
let decision = decide_permission("terminal", "any command", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Allow);
fn confirm_requires_confirm() {
t("sudo apt install").confirm(&["sudo\\s"]).is_confirm();
}
#[test]
fn confirm_overrides_global() {
t("sudo reboot")
.confirm(&["sudo\\s"])
.global(true)
.is_confirm();
}
#[test]
fn confirm_overrides_mode_allow() {
t("sudo x")
.confirm(&["sudo"])
.mode(ToolPermissionMode::Allow)
.is_confirm();
}
// confirm beats allow
#[test]
fn test_default_mode_fallthrough() {
// default_mode: Allow - should allow regardless of global setting
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "any command", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Allow);
fn confirm_beats_allow() {
t("git push --force")
.allow(&["^git\\s"])
.confirm(&["--force"])
.is_confirm();
}
#[test]
fn confirm_beats_allow_overlap() {
t("deploy prod")
.allow(&["deploy"])
.confirm(&["prod"])
.is_confirm();
}
#[test]
fn allow_when_confirm_no_match() {
t("git status")
.allow(&["^git\\s"])
.confirm(&["--force"])
.is_allow();
}
// default_mode: Deny - should deny regardless of global setting
// deny beats allow
#[test]
fn deny_beats_allow() {
t("rm -rf /tmp/x")
.allow(&["/tmp/"])
.deny(&["rm\\s+-rf"])
.is_deny();
}
#[test]
fn deny_beats_allow_diff() {
t("bad deploy").allow(&["deploy"]).deny(&["bad"]).is_deny();
}
// deny beats confirm
#[test]
fn deny_beats_confirm() {
t("sudo rm -rf /")
.confirm(&["sudo"])
.deny(&["rm\\s+-rf"])
.is_deny();
}
// deny beats everything
#[test]
fn deny_beats_all() {
t("bad cmd")
.allow(&["cmd"])
.confirm(&["cmd"])
.deny(&["bad"])
.is_deny();
}
// no patterns -> default_mode
#[test]
fn default_confirm() {
t("python x.py")
.mode(ToolPermissionMode::Confirm)
.is_confirm();
}
#[test]
fn default_allow() {
t("python x.py").mode(ToolPermissionMode::Allow).is_allow();
}
#[test]
fn default_deny() {
t("python x.py").mode(ToolPermissionMode::Deny).is_deny();
}
// default_mode confirm + global
#[test]
fn default_confirm_global_false() {
t("x")
.mode(ToolPermissionMode::Confirm)
.global(false)
.is_confirm();
}
#[test]
fn default_confirm_global_true() {
t("x")
.mode(ToolPermissionMode::Confirm)
.global(true)
.is_allow();
}
// no rules at all -> global setting
#[test]
fn no_rules_global_false() {
assert_eq!(no_rules("x", false), ToolPermissionDecision::Confirm);
}
#[test]
fn no_rules_global_true() {
assert_eq!(no_rules("x", true), ToolPermissionDecision::Allow);
}
// empty input
#[test]
fn empty_input_no_match() {
t("").deny(&["rm"]).is_confirm();
}
#[test]
fn empty_input_global() {
t("").deny(&["rm"]).global(true).is_allow();
}
// multiple patterns - any match
#[test]
fn multi_deny_first() {
t("rm x").deny(&["rm", "del", "drop"]).is_deny();
}
#[test]
fn multi_deny_last() {
t("drop x").deny(&["rm", "del", "drop"]).is_deny();
}
#[test]
fn multi_allow_first() {
t("cargo x").allow(&["^cargo", "^npm", "^git"]).is_allow();
}
#[test]
fn multi_allow_last() {
t("git x").allow(&["^cargo", "^npm", "^git"]).is_allow();
}
#[test]
fn multi_none_match() {
t("python x")
.allow(&["^cargo", "^npm"])
.deny(&["rm"])
.is_confirm();
}
// tool isolation
#[test]
fn other_tool_not_affected() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
@@ -270,133 +476,6 @@ mod tests {
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "any command", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
// default_mode: Confirm - respects global always_allow_tool_actions
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Confirm,
always_allow: vec![],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "any command", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Confirm);
let decision = decide_permission("terminal", "any command", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Allow);
}
#[test]
fn test_empty_input() {
let permissions = terminal_rules_with_deny(&["rm"]);
// Empty input doesn't match the deny pattern, so falls through to default_mode (Confirm)
let decision = decide_permission("terminal", "", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Confirm);
// With always_allow_tool_actions=true and default_mode=Confirm, it returns Allow
let decision = decide_permission("terminal", "", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Allow);
}
#[test]
fn test_multiple_patterns_any_match() {
// Multiple deny patterns - any match should deny
let permissions = terminal_rules_with_deny(&["rm", "dangerous", "delete"]);
let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
let decision = decide_permission("terminal", "delete file", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
// Multiple allow patterns - any match should allow
let permissions = terminal_rules_with_allow(&["^cargo", "^npm", "^git"]);
let decision = decide_permission("terminal", "cargo build", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Allow);
let decision = decide_permission("terminal", "npm install", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Allow);
// No pattern matches - falls through to default
let decision = decide_permission("terminal", "rm file", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Confirm);
}
#[test]
fn test_case_insensitive_matching() {
// Case-insensitive by default (case_sensitive: false)
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Confirm,
always_allow: vec![],
always_deny: vec![CompiledRegex::new(r"\brm\b", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
// Should match regardless of case
let decision = decide_permission("terminal", "RM file.txt", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
let decision = decide_permission("terminal", "Rm file.txt", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
let decision = decide_permission("terminal", "rm file.txt", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
}
#[test]
fn test_case_sensitive_matching() {
// Case-sensitive matching when explicitly enabled
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Confirm,
always_allow: vec![],
always_deny: vec![CompiledRegex::new("DROP TABLE", true).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
// Should only match exact case
let decision = decide_permission("terminal", "DROP TABLE users", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
// Should NOT match different case
let decision = decide_permission("terminal", "drop table users", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Allow);
}
#[test]
fn test_multi_tool_isolation() {
// Rules for terminal should not affect edit_file
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Deny,
always_allow: vec![],
always_deny: vec![CompiledRegex::new("dangerous", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
tools.insert(
Arc::from("edit_file"),
ToolRules {
@@ -407,107 +486,19 @@ mod tests {
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
// Terminal with "dangerous" should be denied
let decision = decide_permission("terminal", "run dangerous command", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
// edit_file with "dangerous" should be allowed (no deny rules for edit_file)
let decision = decide_permission("edit_file", "dangerous_file.txt", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Allow);
// Terminal without "dangerous" should still be denied due to default_mode: Deny
let decision = decide_permission("terminal", "safe command", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
}
#[test]
fn test_invalid_patterns_block_tool() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![InvalidRegexPattern {
pattern: "[invalid(regex".to_string(),
rule_type: "always_deny".to_string(),
error: "unclosed character class".to_string(),
}],
},
let p = ToolPermissions { tools };
assert!(matches!(
decide_permission("terminal", "x", &p, true),
ToolPermissionDecision::Deny(_)
));
assert_eq!(
decide_permission("edit_file", "x", &p, false),
ToolPermissionDecision::Allow
);
let permissions = ToolPermissions { tools };
// Even though "echo" matches always_allow, the tool should be blocked
// because there are invalid patterns
let decision = decide_permission("terminal", "echo hello", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
if let ToolPermissionDecision::Deny(msg) = decision {
assert!(
msg.contains("regex"),
"error message should mention regex: {}",
msg
);
assert!(
msg.contains("settings"),
"error message should mention settings: {}",
msg
);
assert!(
msg.contains("terminal"),
"error message should mention the tool name: {}",
msg
);
}
}
#[test]
fn test_same_pattern_in_deny_and_allow_deny_wins() {
// When the same pattern appears in both deny and allow lists, deny should win
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![CompiledRegex::new("deploy", false).unwrap()],
always_deny: vec![CompiledRegex::new("deploy", false).unwrap()],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "deploy production", &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
}
#[test]
fn test_same_pattern_in_confirm_and_allow_confirm_wins() {
// When the same pattern appears in both confirm and allow lists, confirm should win
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![CompiledRegex::new("deploy", false).unwrap()],
always_deny: vec![],
always_confirm: vec![CompiledRegex::new("deploy", false).unwrap()],
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
let decision = decide_permission("terminal", "deploy production", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Confirm);
}
#[test]
fn test_partial_tool_name_does_not_match() {
// Rules for "term" should not affect "terminal"
fn partial_tool_name_no_match() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("term"),
@@ -519,29 +510,115 @@ mod tests {
invalid_patterns: vec![],
},
);
let permissions = ToolPermissions { tools };
// "terminal" should not be affected by "term" rules, falls back to global setting
let decision = decide_permission("terminal", "echo hello", &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Allow);
let decision = decide_permission("terminal", "echo hello", &permissions, false);
assert_eq!(decision, ToolPermissionDecision::Confirm);
let p = ToolPermissions { tools };
assert_eq!(
decide_permission("terminal", "x", &p, true),
ToolPermissionDecision::Allow
);
}
// invalid patterns block the tool
#[test]
fn test_very_long_input() {
// Test that very long inputs are handled correctly
let permissions = terminal_rules_with_deny(&[r"\brm\b"]);
fn invalid_pattern_blocks() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![CompiledRegex::new("echo", false).unwrap()],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![InvalidRegexPattern {
pattern: "[bad".into(),
rule_type: "always_deny".into(),
error: "err".into(),
}],
},
);
let p = ToolPermissions { tools };
assert!(matches!(
decide_permission("terminal", "echo hi", &p, true),
ToolPermissionDecision::Deny(_)
));
}
// Long input without the pattern should not match
let long_safe_input = "echo ".to_string() + &"a".repeat(100_000);
let decision = decide_permission("terminal", &long_safe_input, &permissions, true);
assert_eq!(decision, ToolPermissionDecision::Allow);
// user scenario: only echo allowed, git should confirm
#[test]
fn user_scenario_only_echo() {
t("echo hello").allow(&["^echo\\s"]).is_allow();
}
#[test]
fn user_scenario_git_confirms() {
t("git status").allow(&["^echo\\s"]).is_confirm();
}
#[test]
fn user_scenario_rm_confirms() {
t("rm -rf /").allow(&["^echo\\s"]).is_confirm();
}
// Long input with the pattern should match
let long_dangerous_input = "a".repeat(50_000) + " rm " + &"b".repeat(50_000);
let decision = decide_permission("terminal", &long_dangerous_input, &permissions, true);
assert!(matches!(decision, ToolPermissionDecision::Deny(_)));
// mcp tools
#[test]
fn mcp_allow() {
t("")
.tool("mcp:fs:read")
.mode(ToolPermissionMode::Allow)
.is_allow();
}
#[test]
fn mcp_deny() {
t("")
.tool("mcp:bad:del")
.mode(ToolPermissionMode::Deny)
.is_deny();
}
#[test]
fn mcp_confirm() {
t("")
.tool("mcp:gh:issue")
.mode(ToolPermissionMode::Confirm)
.is_confirm();
}
#[test]
fn mcp_confirm_global() {
t("")
.tool("mcp:gh:issue")
.mode(ToolPermissionMode::Confirm)
.global(true)
.is_allow();
}
// mcp vs builtin isolation
#[test]
fn mcp_doesnt_collide_with_builtin() {
let mut tools = collections::HashMap::default();
tools.insert(
Arc::from("terminal"),
ToolRules {
default_mode: ToolPermissionMode::Deny,
always_allow: vec![],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
tools.insert(
Arc::from("mcp:srv:terminal"),
ToolRules {
default_mode: ToolPermissionMode::Allow,
always_allow: vec![],
always_deny: vec![],
always_confirm: vec![],
invalid_patterns: vec![],
},
);
let p = ToolPermissions { tools };
assert!(matches!(
decide_permission("terminal", "x", &p, false),
ToolPermissionDecision::Deny(_)
));
assert_eq!(
decide_permission("mcp:srv:terminal", "x", &p, false),
ToolPermissionDecision::Allow
);
}
}
@@ -9,6 +9,13 @@ use project::context_server_store::{ContextServerStatus, ContextServerStore};
use std::sync::Arc;
use util::ResultExt;
/// Generates a tool ID for an MCP tool that can be used in settings.
///
/// The format is `mcp:<server_id>:<tool_name>` to avoid collisions with built-in tools.
pub fn mcp_tool_id(server_id: &str, tool_name: &str) -> String {
format!("mcp:{}:{}", server_id, tool_name)
}
pub struct ContextServerPrompt {
pub server_id: ContextServerId,
pub prompt: context_server::types::Prompt,
@@ -332,7 +339,14 @@ impl AnyAgentTool for ContextServerTool {
return Task::ready(Err(anyhow!("Context server not found")));
};
let tool_name = self.tool.name.clone();
let authorize = event_stream.authorize(self.initial_title(input.clone(), cx), cx);
let tool_id = mcp_tool_id(&self.server_id.0, &self.tool.name);
let display_name = self.tool.name.clone();
let authorize = event_stream.authorize_third_party_tool(
self.initial_title(input.clone(), cx),
tool_id,
display_name,
cx,
);
cx.spawn(async move |_cx| {
authorize.await?;
@@ -435,3 +449,29 @@ pub fn get_prompt(
Ok(response)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_mcp_tool_id_format() {
assert_eq!(
mcp_tool_id("filesystem", "read_file"),
"mcp:filesystem:read_file"
);
assert_eq!(
mcp_tool_id("github", "create_issue"),
"mcp:github:create_issue"
);
assert_eq!(
mcp_tool_id("my-custom-server", "do_something"),
"mcp:my-custom-server:do_something"
);
// Underscores in names
assert_eq!(mcp_tool_id("my_server", "my_tool"), "mcp:my_server:my_tool");
}
// Note: Tests for MCP tool ID collision with built-in tools and permission
// decisions are in crates/agent/src/tool_permissions.rs to avoid duplication.
}
+5 -1
View File
@@ -103,7 +103,11 @@ impl AgentTool for CopyPathTool {
let authorize = if needs_confirmation {
let src = MarkdownInlineCode(&input.source_path);
let dest = MarkdownInlineCode(&input.destination_path);
Some(event_stream.authorize(format!("Copy {src} to {dest}"), cx))
let context = crate::ToolPermissionContext {
tool_name: "copy_path".to_string(),
input_value: input.source_path.clone(),
};
Some(event_stream.authorize(format!("Copy {src} to {dest}"), context, cx))
} else {
None
};
@@ -80,10 +80,17 @@ impl AgentTool for CreateDirectoryTool {
ToolPermissionDecision::Deny(reason) => {
return Task::ready(Err(anyhow!("{}", reason)));
}
ToolPermissionDecision::Confirm => Some(event_stream.authorize(
format!("Create directory {}", MarkdownInlineCode(&input.path)),
cx,
)),
ToolPermissionDecision::Confirm => {
let context = crate::ToolPermissionContext {
tool_name: "create_directory".to_string(),
input_value: input.path.clone(),
};
Some(event_stream.authorize(
format!("Create directory {}", MarkdownInlineCode(&input.path)),
context,
cx,
))
}
};
let project_path = match self.project.read(cx).find_project_path(&input.path, cx) {
+9 -1
View File
@@ -86,7 +86,15 @@ impl AgentTool for DeletePathTool {
return Task::ready(Err(anyhow!("{}", reason)));
}
ToolPermissionDecision::Confirm => {
Some(event_stream.authorize(format!("Delete {}", MarkdownInlineCode(&path)), cx))
let context = crate::ToolPermissionContext {
tool_name: "delete_path".to_string(),
input_value: path.clone(),
};
Some(event_stream.authorize(
format!("Delete {}", MarkdownInlineCode(&path)),
context,
cx,
))
}
};
+15 -1
View File
@@ -169,8 +169,13 @@ impl EditFileTool {
if path.components().any(|component| {
component.as_os_str() == <_ as AsRef<OsStr>>::as_ref(&local_settings_folder)
}) {
let context = crate::ToolPermissionContext {
tool_name: "edit_file".to_string(),
input_value: path_str.to_string(),
};
return event_stream.authorize(
format!("{} (local settings)", input.display_description),
context,
cx,
);
}
@@ -181,8 +186,13 @@ impl EditFileTool {
if let Ok(canonical_path) = std::fs::canonicalize(&input.path)
&& canonical_path.starts_with(paths::config_dir())
{
let context = crate::ToolPermissionContext {
tool_name: "edit_file".to_string(),
input_value: path_str.to_string(),
};
return event_stream.authorize(
format!("{} (global settings)", input.display_description),
context,
cx,
);
}
@@ -200,7 +210,11 @@ impl EditFileTool {
if project_path.is_some() {
Task::ready(Ok(()))
} else {
event_stream.authorize(&input.display_description, cx)
let context = crate::ToolPermissionContext {
tool_name: "edit_file".to_string(),
input_value: path_str.to_string(),
};
event_stream.authorize(&input.display_description, context, cx)
}
}
}
+11 -3
View File
@@ -155,9 +155,17 @@ impl AgentTool for FetchTool {
ToolPermissionDecision::Deny(reason) => {
return Task::ready(Err(anyhow::anyhow!("{}", reason)));
}
ToolPermissionDecision::Confirm => Some(
event_stream.authorize(format!("Fetch {}", MarkdownInlineCode(&input.url)), cx),
),
ToolPermissionDecision::Confirm => {
let context = crate::ToolPermissionContext {
tool_name: "fetch".to_string(),
input_value: input.url.clone(),
};
Some(event_stream.authorize(
format!("Fetch {}", MarkdownInlineCode(&input.url)),
context,
cx,
))
}
};
let fetch_task = cx.background_spawn({
+5 -1
View File
@@ -117,7 +117,11 @@ impl AgentTool for MovePathTool {
let authorize = if needs_confirmation {
let src = MarkdownInlineCode(&input.source_path);
let dest = MarkdownInlineCode(&input.destination_path);
Some(event_stream.authorize(format!("Move {src} to {dest}"), cx))
let context = crate::ToolPermissionContext {
tool_name: "move_path".to_string(),
input_value: input.source_path.clone(),
};
Some(event_stream.authorize(format!("Move {src} to {dest}"), context, cx))
} else {
None
};
+6 -1
View File
@@ -66,7 +66,12 @@ impl AgentTool for OpenTool {
) -> Task<Result<Self::Output>> {
// If path_or_url turns out to be a path in the project, make it absolute.
let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
let authorize = event_stream.authorize(self.initial_title(Ok(input.clone()), cx), cx);
let context = crate::ToolPermissionContext {
tool_name: "open".to_string(),
input_value: input.path_or_url.clone(),
};
let authorize =
event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx);
cx.background_spawn(async move {
futures::select! {
result = authorize.fuse() => result?,
+10 -1
View File
@@ -107,7 +107,16 @@ impl AgentTool for SaveFileTool {
format!("Save {}", paths.join(", "))
}
};
Some(event_stream.authorize(title, cx))
let first_path = input
.paths
.first()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
let context = crate::ToolPermissionContext {
tool_name: "save_file".to_string(),
input_value: first_path,
};
Some(event_stream.authorize(title, context, cx))
} else {
None
};
+1
View File
@@ -531,6 +531,7 @@ fn forward_event_to_acp_thread(
tool_call,
options,
response,
..
}) => {
let outcome_task = acp_thread.update(cx, |thread, cx| {
thread.request_tool_call_authorization(tool_call, options, true, cx)
+5 -2
View File
@@ -105,8 +105,11 @@ impl AgentTool for TerminalTool {
return Task::ready(Err(anyhow::anyhow!("{}", reason)));
}
ToolPermissionDecision::Confirm => {
// Use authorize_required since permission rules already determined confirmation is needed
Some(event_stream.authorize_required(self.initial_title(Ok(input.clone()), cx), cx))
let context = crate::ToolPermissionContext {
tool_name: "terminal".to_string(),
input_value: input.command.clone(),
};
Some(event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx))
}
};
cx.spawn(async move |cx| {
+11 -4
View File
@@ -81,10 +81,17 @@ impl AgentTool for WebSearchTool {
ToolPermissionDecision::Deny(reason) => {
return Task::ready(Err(anyhow!("{}", reason)));
}
ToolPermissionDecision::Confirm => Some(event_stream.authorize(
format!("Search the web for {}", MarkdownInlineCode(&input.query)),
cx,
)),
ToolPermissionDecision::Confirm => {
let context = crate::ToolPermissionContext {
tool_name: "web_search".to_string(),
input_value: input.query.clone(),
};
Some(event_stream.authorize(
format!("Search the web for {}", MarkdownInlineCode(&input.query)),
context,
cx,
))
}
};
let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else {
+108 -42
View File
@@ -600,11 +600,6 @@ mod tests {
!terminal.always_confirm.is_empty(),
"terminal should have confirm rules"
);
assert!(
!terminal.always_allow.is_empty(),
"terminal should have allow rules"
);
let edit_file = permissions
.tools
.get("edit_file")
@@ -627,9 +622,10 @@ mod tests {
.tools
.get("fetch")
.expect("fetch tool should be configured");
assert!(
!fetch.always_allow.is_empty(),
"fetch should have allow rules"
assert_eq!(
fetch.default_mode,
settings::ToolPermissionMode::Confirm,
"fetch should have confirm as default mode"
);
}
@@ -665,40 +661,6 @@ mod tests {
}
}
#[test]
fn test_default_allow_rules_match_safe_commands() {
let default_json = include_str!("../../../assets/settings/default.json");
let value: serde_json::Value = serde_json_lenient::from_str(default_json).unwrap();
let tool_permissions = value["agent"]["tool_permissions"].clone();
let content: ToolPermissionsContent = serde_json::from_value(tool_permissions).unwrap();
let permissions = compile_tool_permissions(Some(content));
let terminal = permissions.tools.get("terminal").unwrap();
let safe_commands = [
"cargo build",
"cargo test",
"cargo check",
"npm test",
"pnpm install",
"yarn run build",
"ls",
"ls -la",
"cat file.txt",
"git status",
"git log",
"git diff",
];
for cmd in &safe_commands {
assert!(
terminal.always_allow.iter().any(|r| r.is_match(cmd)),
"Command '{}' should be allowed by allow rules",
cmd
);
}
}
#[test]
fn test_deny_takes_precedence_over_allow_and_confirm() {
let json = json!({
@@ -822,4 +784,108 @@ mod tests {
"Default deny rules should block the classic fork bomb"
);
}
#[test]
fn test_compiled_regex_stores_case_sensitivity() {
let case_sensitive = CompiledRegex::new("test", true).unwrap();
let case_insensitive = CompiledRegex::new("test", false).unwrap();
assert!(case_sensitive.case_sensitive);
assert!(!case_insensitive.case_sensitive);
}
#[test]
fn test_invalid_regex_is_skipped_not_fail() {
let json = json!({
"tools": {
"terminal": {
"always_deny": [
{ "pattern": "[invalid(regex" },
{ "pattern": "valid_pattern" }
]
}
}
});
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
let permissions = compile_tool_permissions(Some(content));
let terminal = permissions.tools.get("terminal").unwrap();
assert_eq!(terminal.always_deny.len(), 1);
assert!(terminal.always_deny[0].is_match("valid_pattern"));
}
#[test]
fn test_unconfigured_tool_not_in_permissions() {
let json = json!({
"tools": {
"terminal": {
"default_mode": "allow"
}
}
});
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
let permissions = compile_tool_permissions(Some(content));
assert!(permissions.tools.contains_key("terminal"));
assert!(!permissions.tools.contains_key("edit_file"));
assert!(!permissions.tools.contains_key("fetch"));
}
#[test]
fn test_always_allow_pattern_only_matches_specified_commands() {
// Reproduces user-reported bug: when always_allow has pattern "^echo\s",
// only "echo hello" should be allowed, not "git status".
//
// User config:
// always_allow_tool_actions: false
// tool_permissions.tools.terminal.always_allow: [{ pattern: "^echo\\s" }]
let json = json!({
"tools": {
"terminal": {
"always_allow": [
{ "pattern": "^echo\\s" }
]
}
}
});
let content: ToolPermissionsContent = serde_json::from_value(json).unwrap();
let permissions = compile_tool_permissions(Some(content));
let terminal = permissions.tools.get("terminal").unwrap();
// Verify the pattern was compiled
assert_eq!(
terminal.always_allow.len(),
1,
"Should have one always_allow pattern"
);
// Verify the pattern matches "echo hello"
assert!(
terminal.always_allow[0].is_match("echo hello"),
"Pattern ^echo\\s should match 'echo hello'"
);
// Verify the pattern does NOT match "git status"
assert!(
!terminal.always_allow[0].is_match("git status"),
"Pattern ^echo\\s should NOT match 'git status'"
);
// Verify the pattern does NOT match "echoHello" (no space)
assert!(
!terminal.always_allow[0].is_match("echoHello"),
"Pattern ^echo\\s should NOT match 'echoHello' (requires whitespace)"
);
// Verify default_mode is Confirm (the default)
assert_eq!(
terminal.default_mode,
settings::ToolPermissionMode::Confirm,
"default_mode should be Confirm when not specified"
);
}
}
File diff suppressed because it is too large. Load diff
+28
View File
@@ -129,9 +129,37 @@ actions!(
SendNextQueuedMessage,
/// Clears all messages from the queue.
ClearMessageQueue,
/// Opens the permission granularity dropdown for the current tool call.
OpenPermissionDropdown,
]
);
/// Action to authorize a tool call with a specific permission option.
/// This is used by the permission granularity dropdown to authorize tool calls.
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(deny_unknown_fields)]
pub struct AuthorizeToolCall {
/// The tool call ID to authorize.
pub tool_call_id: String,
/// The permission option ID to use.
pub option_id: String,
/// The kind of permission option (serialized as string).
pub option_kind: String,
}
/// Action to select a permission granularity option from the dropdown.
/// This updates the selected granularity without triggering authorization.
#[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
#[serde(deny_unknown_fields)]
pub struct SelectPermissionGranularity {
/// The tool call ID for which to select the granularity.
pub tool_call_id: String,
/// The index of the selected granularity option.
pub index: usize,
}
/// Creates a new conversation thread, optionally based on an existing thread.
#[derive(Default, Clone, PartialEq, Deserialize, JsonSchema, Action)]
#[action(namespace = agent)]
+219 -1
View File
@@ -201,6 +201,45 @@ impl AgentSettingsContent {
pub fn remove_favorite_model(&mut self, model: &LanguageModelSelection) {
self.favorite_models.retain(|m| m != model);
}
pub fn set_tool_default_mode(&mut self, tool_id: &str, mode: ToolPermissionMode) {
let tool_permissions = self.tool_permissions.get_or_insert_default();
let tool_rules = tool_permissions
.tools
.entry(Arc::from(tool_id))
.or_default();
tool_rules.default_mode = Some(mode);
}
pub fn add_tool_allow_pattern(&mut self, tool_name: &str, pattern: String) {
let tool_permissions = self.tool_permissions.get_or_insert_default();
let tool_rules = tool_permissions
.tools
.entry(Arc::from(tool_name))
.or_default();
let always_allow = tool_rules.always_allow.get_or_insert_default();
if !always_allow.0.iter().any(|r| r.pattern == pattern) {
always_allow.0.push(ToolRegexRule {
pattern,
case_sensitive: None,
});
}
}
pub fn add_tool_deny_pattern(&mut self, tool_name: &str, pattern: String) {
let tool_permissions = self.tool_permissions.get_or_insert_default();
let tool_rules = tool_permissions
.tools
.entry(Arc::from(tool_name))
.or_default();
let always_deny = tool_rules.always_deny.get_or_insert_default();
if !always_deny.0.iter().any(|r| r.pattern == pattern) {
always_deny.0.push(ToolRegexRule {
pattern,
case_sensitive: None,
});
}
}
}
#[with_fallible_options]
@@ -513,9 +552,10 @@ pub struct ToolRulesContent {
}
#[with_fallible_options]
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
pub struct ToolRegexRule {
/// The regex pattern to match.
#[serde(default)]
pub pattern: String,
/// Whether the regex is case-sensitive.
@@ -536,3 +576,181 @@ pub enum ToolPermissionMode {
#[default]
Confirm,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_set_tool_default_mode_creates_structure() {
let mut settings = AgentSettingsContent::default();
assert!(settings.tool_permissions.is_none());
settings.set_tool_default_mode("terminal", ToolPermissionMode::Allow);
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
assert_eq!(terminal_rules.default_mode, Some(ToolPermissionMode::Allow));
}
#[test]
fn test_set_tool_default_mode_updates_existing() {
let mut settings = AgentSettingsContent::default();
settings.set_tool_default_mode("terminal", ToolPermissionMode::Confirm);
settings.set_tool_default_mode("terminal", ToolPermissionMode::Allow);
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
assert_eq!(terminal_rules.default_mode, Some(ToolPermissionMode::Allow));
}
#[test]
fn test_set_tool_default_mode_for_mcp_tool() {
let mut settings = AgentSettingsContent::default();
settings.set_tool_default_mode("mcp:github:create_issue", ToolPermissionMode::Allow);
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let mcp_rules = tool_permissions
.tools
.get("mcp:github:create_issue")
.unwrap();
assert_eq!(mcp_rules.default_mode, Some(ToolPermissionMode::Allow));
}
#[test]
fn test_add_tool_allow_pattern_creates_structure() {
let mut settings = AgentSettingsContent::default();
assert!(settings.tool_permissions.is_none());
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(always_allow.0.len(), 1);
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
}
#[test]
fn test_add_tool_allow_pattern_appends_to_existing() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("terminal", "^npm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(always_allow.0.len(), 2);
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
assert_eq!(always_allow.0[1].pattern, "^npm\\s");
}
#[test]
fn test_add_tool_allow_pattern_does_not_duplicate() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(
always_allow.0.len(),
1,
"Duplicate patterns should not be added"
);
}
#[test]
fn test_add_tool_allow_pattern_for_different_tools() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_allow_pattern("fetch", "^https?://github\\.com".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
assert_eq!(
terminal_rules.always_allow.as_ref().unwrap().0[0].pattern,
"^cargo\\s"
);
let fetch_rules = tool_permissions.tools.get("fetch").unwrap();
assert_eq!(
fetch_rules.always_allow.as_ref().unwrap().0[0].pattern,
"^https?://github\\.com"
);
}
#[test]
fn test_add_tool_deny_pattern_creates_structure() {
let mut settings = AgentSettingsContent::default();
assert!(settings.tool_permissions.is_none());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(always_deny.0.len(), 1);
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
}
#[test]
fn test_add_tool_deny_pattern_appends_to_existing() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^sudo\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(always_deny.0.len(), 2);
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
assert_eq!(always_deny.0[1].pattern, "^sudo\\s");
}
#[test]
fn test_add_tool_deny_pattern_does_not_duplicate() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(
always_deny.0.len(),
1,
"Duplicate patterns should not be added"
);
}
#[test]
fn test_add_tool_deny_and_allow_patterns_separate() {
let mut settings = AgentSettingsContent::default();
settings.add_tool_allow_pattern("terminal", "^cargo\\s".to_string());
settings.add_tool_deny_pattern("terminal", "^rm\\s".to_string());
let tool_permissions = settings.tool_permissions.as_ref().unwrap();
let terminal_rules = tool_permissions.tools.get("terminal").unwrap();
let always_allow = terminal_rules.always_allow.as_ref().unwrap();
assert_eq!(always_allow.0.len(), 1);
assert_eq!(always_allow.0[0].pattern, "^cargo\\s");
let always_deny = terminal_rules.always_deny.as_ref().unwrap();
assert_eq!(always_deny.0.len(), 1);
assert_eq!(always_deny.0[0].pattern, "^rm\\s");
}
}
+26
View File
@@ -7,6 +7,32 @@ use std::{path::PathBuf, sync::Arc, time::Duration};
pub const EMPTY_THEME_NAME: &str = "empty-theme";
/// Settings for visual tests that use proper fonts instead of Courier.
/// Uses Helvetica Neue for UI (sans-serif) and Menlo for code (monospace),
/// which are available on all macOS systems.
#[cfg(any(test, feature = "test-support"))]
pub fn visual_test_settings() -> String {
let mut value =
crate::parse_json_with_comments::<serde_json::Value>(crate::default_settings().as_ref())
.unwrap();
util::merge_non_null_json_value_into(
serde_json::json!({
"ui_font_family": ".SystemUIFont",
"ui_font_features": {},
"ui_font_size": 14,
"ui_font_fallback": [],
"buffer_font_family": "Menlo",
"buffer_font_features": {},
"buffer_font_size": 14,
"buffer_font_fallbacks": [],
"theme": EMPTY_THEME_NAME,
}),
&mut value,
);
value.as_object_mut().unwrap().remove("languages");
serde_json::to_string(&value).unwrap()
}
#[cfg(any(test, feature = "test-support"))]
pub fn test_settings() -> String {
let mut value =