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:
1 parent
be110b3836
commit
dd4699dc72
29 files changed
+2599
-486
No files matched your search
Generated
+1
@@ -217,6 +217,7 @@ dependencies = [
|
||||
"tree-sitter-rust",
|
||||
"ui",
|
||||
"unindent",
|
||||
"url",
|
||||
"util",
|
||||
"uuid",
|
||||
"watch",
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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?,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)]
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
Reference in new issue
Block a user