Replace always_allow_tool_actions with tool_permissions.default (#48553)
<img width="1275" height="154" alt="Screenshot 2026-02-10 at 6 55 03 PM" src="https://github.com/user-attachments/assets/db87fe3f-274b-4bca-b342-b72e651ce57c" /> <img width="1275" height="176" alt="Screenshot 2026-02-10 at 6 55 15 PM" src="https://github.com/user-attachments/assets/4b9a0e70-4897-4736-8cc1-8d1415508f58" /> Replaces the boolean `always_allow_tool_actions` setting with a three-valued `tool_permissions.default` field (`"allow"` / `"confirm"` / `"deny"`). Release Notes: - Introduced per-tool permission settings, including regexes for controlling when tools may be auto-allowed, auto-denied, or always require confirmation. - Replaced the `always_allow_tool_actions` setting with `tool_permissions.default`. --------- Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com>
This commit is contained in:
1 parent
945e67f5e7
commit
2f949d14a7
45 files changed
+3086
-724
No files matched your search
Generated
-1
@@ -8,7 +8,6 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"action_log",
|
||||
"agent-client-protocol",
|
||||
"agent_settings",
|
||||
"anyhow",
|
||||
"base64 0.22.1",
|
||||
"buffer_diff",
|
||||
|
||||
@@ -958,20 +958,29 @@
|
||||
// "temperature": 1.0
|
||||
// }
|
||||
],
|
||||
// When enabled, the agent can run tool actions without asking for your confirmation.
|
||||
// This setting takes precedence over `always_confirm` patterns and `default_mode` settings,
|
||||
// but `always_deny` patterns still block actions for security.
|
||||
// Permission rules for tool actions.
|
||||
//
|
||||
// Note: This setting has no effect on external agents that support permission modes, such as Claude Code.
|
||||
// You can set `agent_servers.claude.default_mode` to `bypassPermissions` to skip all permission requests.
|
||||
"always_allow_tool_actions": false,
|
||||
// Per-tool permission rules for granular control over tool actions.
|
||||
// This setting only applies to the native Zed agent.
|
||||
// The "default" setting applies when no tool-specific rules match.
|
||||
// For external agents that define their own permission modes,
|
||||
// "deny" and "confirm" still take precedence — the external agent's
|
||||
// permission system is only used when Zed would allow the action.
|
||||
//
|
||||
// Per-tool regex patterns ("tools" below) match against tool input text
|
||||
// (commands, paths, URLs, etc.). For `copy_path` and `move_path`,
|
||||
// patterns are matched independently against each path (source and
|
||||
// destination).
|
||||
"tool_permissions": {
|
||||
// Here are some examples of tool-specific permissions.
|
||||
// Global default permission when no tool-specific rules match.
|
||||
// "allow" - Auto-approve without prompting
|
||||
// "deny" - Auto-reject
|
||||
// "confirm" - Always prompt (default)
|
||||
"default": "confirm",
|
||||
// Per-tool permission rules. Regex patterns match against tool input text.
|
||||
// The per-tool "default" also applies to MCP tools.
|
||||
// Each tool can have its own default and regex patterns.
|
||||
"tools": {
|
||||
// "terminal": {
|
||||
// "default_mode": "confirm",
|
||||
// "default": "confirm",
|
||||
// "always_confirm": [
|
||||
// // Destructive git operations
|
||||
// { "pattern": "git\\s+(reset|clean)\\s+--hard" },
|
||||
@@ -979,7 +988,7 @@
|
||||
// ],
|
||||
// },
|
||||
// "edit_file": {
|
||||
// "default_mode": "confirm",
|
||||
// "default": "confirm",
|
||||
// "always_deny": [
|
||||
// // Secrets and credentials
|
||||
// { "pattern": "\\.env($|\\.)" },
|
||||
@@ -2235,9 +2244,9 @@
|
||||
// Whether to show the LSP servers button in the status bar.
|
||||
"button": true,
|
||||
// The maximum amount of time to wait for responses from language servers, in seconds.
|
||||
// A value of 0 will result in no timeout being applied.
|
||||
//
|
||||
// A value of `0` will result in no timeout being applied (causing all LSP responses to wait
|
||||
// indefinitely until completed).
|
||||
// Default: 120
|
||||
"request_timeout": 120,
|
||||
"notifications": {
|
||||
// Timeout in milliseconds for automatically dismissing language server notifications.
|
||||
|
||||
@@ -19,7 +19,6 @@ test-support = ["gpui/test-support", "project/test-support", "dep:parking_lot",
|
||||
action_log.workspace = true
|
||||
agent-client-protocol.workspace = true
|
||||
base64.workspace = true
|
||||
agent_settings.workspace = true
|
||||
anyhow.workspace = true
|
||||
buffer_diff.workspace = true
|
||||
chrono.workspace = true
|
||||
|
||||
@@ -3,8 +3,6 @@ mod diff;
|
||||
mod mention;
|
||||
mod terminal;
|
||||
|
||||
use agent_settings::AgentSettings;
|
||||
|
||||
/// Key used in ACP ToolCall meta to store the tool's programmatic name.
|
||||
/// This is a workaround since ACP's ToolCall doesn't have a dedicated name field.
|
||||
pub const TOOL_NAME_META_KEY: &str = "tool_name";
|
||||
@@ -40,7 +38,7 @@ pub use mention::*;
|
||||
use project::lsp_store::{FormatTrigger, LspFormatTarget};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::to_string_pretty;
|
||||
use settings::Settings as _;
|
||||
|
||||
use task::{Shell, ShellBuilder};
|
||||
pub use terminal::*;
|
||||
|
||||
@@ -1733,25 +1731,10 @@ impl AcpThread {
|
||||
&mut self,
|
||||
tool_call: acp::ToolCallUpdate,
|
||||
options: PermissionOptions,
|
||||
respect_always_allow_setting: bool,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Result<BoxFuture<'static, acp::RequestPermissionOutcome>> {
|
||||
let (tx, rx) = oneshot::channel();
|
||||
|
||||
if respect_always_allow_setting && AgentSettings::get_global(cx).always_allow_tool_actions {
|
||||
// Don't use AllowAlways, because then if you were to turn off always_allow_tool_actions,
|
||||
// some tools would (incorrectly) continue to auto-accept.
|
||||
if let Some(allow_once_option) = options.allow_once_option_id() {
|
||||
self.upsert_tool_call_inner(tool_call, ToolCallStatus::Pending, cx)?;
|
||||
return Ok(async {
|
||||
acp::RequestPermissionOutcome::Selected(acp::SelectedPermissionOutcome::new(
|
||||
allow_once_option,
|
||||
))
|
||||
}
|
||||
.boxed());
|
||||
}
|
||||
}
|
||||
|
||||
let status = ToolCallStatus::WaitingForConfirmation {
|
||||
options,
|
||||
respond_tx: tx,
|
||||
|
||||
@@ -477,6 +477,11 @@ impl PermissionOptions {
|
||||
self.first_option_of_kind(acp::PermissionOptionKind::AllowOnce)
|
||||
.map(|option| option.option_id.clone())
|
||||
}
|
||||
|
||||
pub fn deny_once_option_id(&self) -> Option<acp::PermissionOptionId> {
|
||||
self.first_option_of_kind(acp::PermissionOptionKind::RejectOnce)
|
||||
.map(|option| option.option_id.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "test-support")]
|
||||
@@ -689,7 +694,6 @@ mod test_support {
|
||||
thread.request_tool_call_authorization(
|
||||
tool_call.clone().into(),
|
||||
options.clone(),
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
})??
|
||||
|
||||
@@ -16,6 +16,7 @@ use context_server::ContextServerId;
|
||||
pub use db::*;
|
||||
pub use native_agent_server::NativeAgentServer;
|
||||
pub use pattern_extraction::*;
|
||||
pub use shell_command_parser::extract_commands;
|
||||
pub use templates::*;
|
||||
pub use thread::*;
|
||||
pub use thread_store::*;
|
||||
@@ -1038,9 +1039,7 @@ impl NativeAgentConnection {
|
||||
context: _,
|
||||
}) => {
|
||||
let outcome_task = acp_thread.update(cx, |thread, cx| {
|
||||
thread.request_tool_call_authorization(
|
||||
tool_call, options, true, cx,
|
||||
)
|
||||
thread.request_tool_call_authorization(tool_call, options, cx)
|
||||
})??;
|
||||
cx.background_spawn(async move {
|
||||
if let acp::RequestPermissionOutcome::Selected(
|
||||
|
||||
+126
-46
@@ -280,7 +280,7 @@ impl crate::ThreadEnvironment for MultiTerminalEnvironment {
|
||||
fn always_allow_tools(cx: &mut TestAppContext) {
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = true;
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
}
|
||||
@@ -1037,9 +1037,11 @@ async fn next_tool_call_authorization(
|
||||
|
||||
#[test]
|
||||
fn test_permission_options_terminal_with_pattern() {
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
|
||||
.build_permission_options();
|
||||
let permission_options = ToolPermissionContext::new(
|
||||
TerminalTool::NAME,
|
||||
vec!["cargo build --release".to_string()],
|
||||
)
|
||||
.build_permission_options();
|
||||
|
||||
let PermissionOptions::Dropdown(choices) = permission_options else {
|
||||
panic!("Expected dropdown permission options");
|
||||
@@ -1058,7 +1060,8 @@ fn test_permission_options_terminal_with_pattern() {
|
||||
#[test]
|
||||
fn test_permission_options_edit_file_with_path_pattern() {
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(EditFileTool::NAME, "src/main.rs").build_permission_options();
|
||||
ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let PermissionOptions::Dropdown(choices) = permission_options else {
|
||||
panic!("Expected dropdown permission options");
|
||||
@@ -1074,8 +1077,9 @@ fn test_permission_options_edit_file_with_path_pattern() {
|
||||
|
||||
#[test]
|
||||
fn test_permission_options_fetch_with_domain_pattern() {
|
||||
let permission_options = ToolPermissionContext::new(FetchTool::NAME, "https://docs.rs/gpui")
|
||||
.build_permission_options();
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let PermissionOptions::Dropdown(choices) = permission_options else {
|
||||
panic!("Expected dropdown permission options");
|
||||
@@ -1091,9 +1095,11 @@ fn test_permission_options_fetch_with_domain_pattern() {
|
||||
|
||||
#[test]
|
||||
fn test_permission_options_without_pattern() {
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "./deploy.sh --production")
|
||||
.build_permission_options();
|
||||
let permission_options = ToolPermissionContext::new(
|
||||
TerminalTool::NAME,
|
||||
vec!["./deploy.sh --production".to_string()],
|
||||
)
|
||||
.build_permission_options();
|
||||
|
||||
let PermissionOptions::Dropdown(choices) = permission_options else {
|
||||
panic!("Expected dropdown permission options");
|
||||
@@ -1111,9 +1117,11 @@ fn test_permission_options_without_pattern() {
|
||||
|
||||
#[test]
|
||||
fn test_permission_option_ids_for_terminal() {
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
|
||||
.build_permission_options();
|
||||
let permission_options = ToolPermissionContext::new(
|
||||
TerminalTool::NAME,
|
||||
vec!["cargo build --release".to_string()],
|
||||
)
|
||||
.build_permission_options();
|
||||
|
||||
let PermissionOptions::Dropdown(choices) = permission_options else {
|
||||
panic!("Expected dropdown permission options");
|
||||
@@ -1288,7 +1296,7 @@ async fn test_mcp_tools(cx: &mut TestAppContext) {
|
||||
paths::settings_file(),
|
||||
json!({
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true,
|
||||
"tool_permissions": { "default": "allow" },
|
||||
"profiles": {
|
||||
"test": {
|
||||
"name": "Test Profile",
|
||||
@@ -3731,7 +3739,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
settings.tool_permissions.tools.insert(
|
||||
TerminalTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Confirm,
|
||||
default: Some(settings::ToolPermissionMode::Confirm),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![
|
||||
agent_settings::CompiledRegex::new(r"rm\s+-rf", false).unwrap(),
|
||||
@@ -3771,7 +3779,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Test 2: Allow rule skips confirmation (and overrides default_mode: Deny)
|
||||
// Test 2: Allow rule skips confirmation (and overrides default: Deny)
|
||||
{
|
||||
let environment = Rc::new(cx.update(|cx| {
|
||||
FakeThreadEnvironment::default()
|
||||
@@ -3780,11 +3788,10 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = false;
|
||||
settings.tool_permissions.tools.insert(
|
||||
TerminalTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Deny,
|
||||
default: Some(settings::ToolPermissionMode::Deny),
|
||||
always_allow: vec![
|
||||
agent_settings::CompiledRegex::new(r"^echo\s", false).unwrap(),
|
||||
],
|
||||
@@ -3829,7 +3836,7 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
);
|
||||
}
|
||||
|
||||
// Test 3: always_allow_tool_actions=true overrides always_confirm patterns
|
||||
// Test 3: global default: allow does NOT override always_confirm patterns
|
||||
{
|
||||
let environment = Rc::new(cx.update(|cx| {
|
||||
FakeThreadEnvironment::default()
|
||||
@@ -3838,11 +3845,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = true;
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
||||
settings.tool_permissions.tools.insert(
|
||||
TerminalTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![
|
||||
@@ -3856,9 +3863,9 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::TerminalTool::new(project.clone(), environment));
|
||||
let (event_stream, _rx) = crate::ToolCallEventStream::test();
|
||||
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
|
||||
|
||||
let task = cx.update(|cx| {
|
||||
let _task = cx.update(|cx| {
|
||||
tool.run(
|
||||
crate::TerminalToolInput {
|
||||
command: "sudo rm file".to_string(),
|
||||
@@ -3870,12 +3877,15 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
)
|
||||
});
|
||||
|
||||
// With always_allow_tool_actions=true, confirm patterns are overridden
|
||||
task.await
|
||||
.expect("command should be allowed with always_allow_tool_actions=true");
|
||||
// With global default: allow, confirm patterns are still respected
|
||||
// The expect_authorization() call will panic if no authorization is requested,
|
||||
// which validates that the confirm pattern still triggers confirmation
|
||||
let _auth = rx.expect_authorization().await;
|
||||
|
||||
drop(_task);
|
||||
}
|
||||
|
||||
// Test 4: always_allow_tool_actions=true overrides default_mode: Deny
|
||||
// Test 4: tool-specific default: deny is respected even with global default: allow
|
||||
{
|
||||
let environment = Rc::new(cx.update(|cx| {
|
||||
FakeThreadEnvironment::default()
|
||||
@@ -3884,11 +3894,11 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = true;
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
||||
settings.tool_permissions.tools.insert(
|
||||
TerminalTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Deny,
|
||||
default: Some(settings::ToolPermissionMode::Deny),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
@@ -3914,9 +3924,17 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
|
||||
)
|
||||
});
|
||||
|
||||
// With always_allow_tool_actions=true, even default_mode: Deny is overridden
|
||||
task.await
|
||||
.expect("command should be allowed with always_allow_tool_actions=true");
|
||||
// tool-specific default: deny is respected even with global default: allow
|
||||
let result = task.await;
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"expected command to be blocked by tool-specific deny default"
|
||||
);
|
||||
let err_msg = result.unwrap_err().to_string().to_lowercase();
|
||||
assert!(
|
||||
err_msg.contains("disabled"),
|
||||
"error should mention the tool is disabled, got: {err_msg}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4625,7 +4643,7 @@ async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
|
||||
settings.tool_permissions.tools.insert(
|
||||
EditFileTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"sensitive", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
@@ -4693,7 +4711,7 @@ async fn test_delete_path_tool_deny_rule_blocks_deletion(cx: &mut TestAppContext
|
||||
settings.tool_permissions.tools.insert(
|
||||
DeletePathTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"important", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
@@ -4747,7 +4765,7 @@ async fn test_move_path_tool_denies_if_destination_denied(cx: &mut TestAppContex
|
||||
settings.tool_permissions.tools.insert(
|
||||
MovePathTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"protected", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
@@ -4803,7 +4821,7 @@ async fn test_move_path_tool_denies_if_source_denied(cx: &mut TestAppContext) {
|
||||
settings.tool_permissions.tools.insert(
|
||||
MovePathTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"secret", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
@@ -4859,7 +4877,7 @@ async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) {
|
||||
settings.tool_permissions.tools.insert(
|
||||
CopyPathTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![
|
||||
agent_settings::CompiledRegex::new(r"confidential", false).unwrap(),
|
||||
@@ -4916,7 +4934,7 @@ async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext)
|
||||
settings.tool_permissions.tools.insert(
|
||||
SaveFileTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"readonly", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
@@ -4965,11 +4983,10 @@ async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) {
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = false;
|
||||
settings.tool_permissions.tools.insert(
|
||||
SaveFileTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![agent_settings::CompiledRegex::new(r"\.secret$", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
@@ -5010,7 +5027,7 @@ async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) {
|
||||
settings.tool_permissions.tools.insert(
|
||||
WebSearchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![
|
||||
agent_settings::CompiledRegex::new(r"internal\.company", false).unwrap(),
|
||||
@@ -5050,11 +5067,10 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = false;
|
||||
settings.tool_permissions.tools.insert(
|
||||
EditFileTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Confirm,
|
||||
default: Some(settings::ToolPermissionMode::Confirm),
|
||||
always_allow: vec![agent_settings::CompiledRegex::new(r"\.md$", false).unwrap()],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
@@ -5109,6 +5125,71 @@ async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppConte
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_edit_file_tool_allow_still_prompts_for_local_settings(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
|
||||
let fs = FakeFs::new(cx.executor());
|
||||
fs.insert_tree(
|
||||
"/root",
|
||||
json!({
|
||||
".zed": {
|
||||
"settings.json": "{}"
|
||||
},
|
||||
"README.md": "# Hello"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let context_server_registry =
|
||||
cx.new(|cx| crate::ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
|
||||
let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
|
||||
let templates = crate::Templates::new();
|
||||
let thread = cx.new(|cx| {
|
||||
crate::Thread::new(
|
||||
project.clone(),
|
||||
cx.new(|_cx| prompt_store::ProjectContext::default()),
|
||||
context_server_registry,
|
||||
templates.clone(),
|
||||
None,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
#[allow(clippy::arc_with_non_send_sync)]
|
||||
let tool = Arc::new(crate::EditFileTool::new(
|
||||
project,
|
||||
thread.downgrade(),
|
||||
language_registry,
|
||||
templates,
|
||||
));
|
||||
|
||||
// Editing a file inside .zed/ should still prompt even with global default: allow,
|
||||
// because local settings paths are sensitive and require confirmation regardless.
|
||||
let (event_stream, mut rx) = crate::ToolCallEventStream::test();
|
||||
let _task = cx.update(|cx| {
|
||||
tool.run(
|
||||
crate::EditFileToolInput {
|
||||
display_description: "Edit local settings".to_string(),
|
||||
path: "root/.zed/settings.json".into(),
|
||||
mode: crate::EditFileMode::Edit,
|
||||
},
|
||||
event_stream,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let _update = rx.expect_update_fields().await;
|
||||
let _auth = rx.expect_authorization().await;
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_fetch_tool_deny_rule_blocks_url(cx: &mut TestAppContext) {
|
||||
init_test(cx);
|
||||
@@ -5118,7 +5199,7 @@ async fn test_fetch_tool_deny_rule_blocks_url(cx: &mut TestAppContext) {
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Allow,
|
||||
default: Some(settings::ToolPermissionMode::Allow),
|
||||
always_allow: vec![],
|
||||
always_deny: vec![
|
||||
agent_settings::CompiledRegex::new(r"internal\.company\.com", false).unwrap(),
|
||||
@@ -5155,11 +5236,10 @@ async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext)
|
||||
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = false;
|
||||
settings.tool_permissions.tools.insert(
|
||||
FetchTool::NAME.into(),
|
||||
agent_settings::ToolRules {
|
||||
default_mode: settings::ToolPermissionMode::Confirm,
|
||||
default: Some(settings::ToolPermissionMode::Confirm),
|
||||
always_allow: vec![agent_settings::CompiledRegex::new(r"docs\.rs", false).unwrap()],
|
||||
always_deny: vec![],
|
||||
always_confirm: vec![],
|
||||
|
||||
@@ -120,7 +120,7 @@ impl AgentTool for ToolRequiringPermission {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let decision = decide_permission_from_settings(Self::NAME, "", settings);
|
||||
let decision = decide_permission_from_settings(Self::NAME, &[String::new()], settings);
|
||||
|
||||
let authorize = match decision {
|
||||
ToolPermissionDecision::Allow => None,
|
||||
@@ -130,7 +130,7 @@ impl AgentTool for ToolRequiringPermission {
|
||||
ToolPermissionDecision::Confirm => {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: "tool_requiring_permission".to_string(),
|
||||
input_value: String::new(),
|
||||
input_values: vec![String::new()],
|
||||
};
|
||||
Some(event_stream.authorize("Authorize?", context, cx))
|
||||
}
|
||||
|
||||
+55
-40
@@ -627,14 +627,14 @@ pub struct NewTerminal {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ToolPermissionContext {
|
||||
pub tool_name: String,
|
||||
pub input_value: String,
|
||||
pub input_values: Vec<String>,
|
||||
}
|
||||
|
||||
impl ToolPermissionContext {
|
||||
pub fn new(tool_name: impl Into<String>, input_value: impl Into<String>) -> Self {
|
||||
pub fn new(tool_name: impl Into<String>, input_values: Vec<String>) -> Self {
|
||||
Self {
|
||||
tool_name: tool_name.into(),
|
||||
input_value: input_value.into(),
|
||||
input_values,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -667,7 +667,7 @@ impl ToolPermissionContext {
|
||||
use util::shell::ShellKind;
|
||||
|
||||
let tool_name = &self.tool_name;
|
||||
let input_value = &self.input_value;
|
||||
let input_values = &self.input_values;
|
||||
|
||||
// Check if the user's shell supports POSIX-like command chaining.
|
||||
// See the doc comment above for the full explanation of why this is needed.
|
||||
@@ -677,35 +677,50 @@ impl ToolPermissionContext {
|
||||
true
|
||||
};
|
||||
|
||||
let (pattern, pattern_display) = if tool_name == TerminalTool::NAME {
|
||||
(
|
||||
extract_terminal_pattern(input_value),
|
||||
extract_terminal_pattern_display(input_value),
|
||||
)
|
||||
} else if tool_name == CopyPathTool::NAME || tool_name == MovePathTool::NAME {
|
||||
// input_value is "source\ndestination"; extract a pattern from the
|
||||
// common parent directory of both paths so that "always allow" covers
|
||||
// future checks against both the source and the destination.
|
||||
(
|
||||
extract_copy_move_pattern(input_value),
|
||||
extract_copy_move_pattern_display(input_value),
|
||||
)
|
||||
} else if tool_name == EditFileTool::NAME
|
||||
|| tool_name == DeletePathTool::NAME
|
||||
|| tool_name == CreateDirectoryTool::NAME
|
||||
|| tool_name == SaveFileTool::NAME
|
||||
{
|
||||
(
|
||||
extract_path_pattern(input_value),
|
||||
extract_path_pattern_display(input_value),
|
||||
)
|
||||
} else if tool_name == FetchTool::NAME {
|
||||
(
|
||||
extract_url_pattern(input_value),
|
||||
extract_url_pattern_display(input_value),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
let extract_for_value = |value: &str| -> (Option<String>, Option<String>) {
|
||||
if tool_name == TerminalTool::NAME {
|
||||
(
|
||||
extract_terminal_pattern(value),
|
||||
extract_terminal_pattern_display(value),
|
||||
)
|
||||
} else if tool_name == CopyPathTool::NAME
|
||||
|| tool_name == MovePathTool::NAME
|
||||
|| tool_name == EditFileTool::NAME
|
||||
|| tool_name == DeletePathTool::NAME
|
||||
|| tool_name == CreateDirectoryTool::NAME
|
||||
|| tool_name == SaveFileTool::NAME
|
||||
{
|
||||
(
|
||||
extract_path_pattern(value),
|
||||
extract_path_pattern_display(value),
|
||||
)
|
||||
} else if tool_name == FetchTool::NAME {
|
||||
(
|
||||
extract_url_pattern(value),
|
||||
extract_url_pattern_display(value),
|
||||
)
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
};
|
||||
|
||||
// Extract patterns from all input values. Only offer a pattern-specific
|
||||
// "always allow/deny" button when every value produces the same pattern.
|
||||
let (pattern, pattern_display) = match input_values.as_slice() {
|
||||
[single] => extract_for_value(single),
|
||||
_ => {
|
||||
let mut iter = input_values.iter().map(|v| extract_for_value(v));
|
||||
match iter.next() {
|
||||
Some(first) => {
|
||||
if iter.all(|pair| pair.0 == first.0) {
|
||||
first
|
||||
} else {
|
||||
(None, None)
|
||||
}
|
||||
}
|
||||
None => (None, None),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let mut choices = Vec::new();
|
||||
@@ -3090,10 +3105,10 @@ impl ToolCallEventStream {
|
||||
/// 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.
|
||||
/// They only support `default` (allow/deny/confirm) per tool.
|
||||
///
|
||||
/// Uses the dropdown authorization flow with two granularities:
|
||||
/// - "Always for <display_name> MCP tool" → sets `tools.<tool_id>.default_mode = "allow"` or "deny"
|
||||
/// - "Always for <display_name> MCP tool" → sets `tools.<tool_id>.default = "allow"` or "deny"
|
||||
/// - "Only this time" → allow/deny once
|
||||
pub fn authorize_third_party_tool(
|
||||
&self,
|
||||
@@ -3104,7 +3119,7 @@ impl ToolCallEventStream {
|
||||
) -> Task<Result<()>> {
|
||||
let settings = agent_settings::AgentSettings::get_global(cx);
|
||||
|
||||
let decision = decide_permission_from_settings(&tool_id, "", &settings);
|
||||
let decision = decide_permission_from_settings(&tool_id, &[String::new()], &settings);
|
||||
|
||||
match decision {
|
||||
ToolPermissionDecision::Allow => return Task::ready(Ok(())),
|
||||
@@ -3176,7 +3191,7 @@ impl ToolCallEventStream {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_tool_default_mode(&tool_id, ToolPermissionMode::Allow);
|
||||
.set_tool_default_permission(&tool_id, ToolPermissionMode::Allow);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3189,7 +3204,7 @@ impl ToolCallEventStream {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_tool_default_mode(&tool_id, ToolPermissionMode::Deny);
|
||||
.set_tool_default_permission(&tool_id, ToolPermissionMode::Deny);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3249,7 +3264,7 @@ impl ToolCallEventStream {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_tool_default_mode(&tool, ToolPermissionMode::Allow);
|
||||
.set_tool_default_permission(&tool, ToolPermissionMode::Allow);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -3265,7 +3280,7 @@ impl ToolCallEventStream {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.set_tool_default_mode(&tool, ToolPermissionMode::Deny);
|
||||
.set_tool_default_permission(&tool, ToolPermissionMode::Deny);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,7 +1,7 @@
|
||||
use super::edit_file_tool::{
|
||||
SensitiveSettingsKind, is_sensitive_settings_path, sensitive_settings_kind,
|
||||
};
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_paths};
|
||||
use agent_client_protocol::ToolKind;
|
||||
use agent_settings::AgentSettings;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
@@ -85,32 +85,23 @@ impl AgentTool for CopyPathTool {
|
||||
) -> Task<Result<Self::Output>> {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
|
||||
let source_decision = decide_permission_for_path(Self::NAME, &input.source_path, settings);
|
||||
if let ToolPermissionDecision::Deny(reason) = source_decision {
|
||||
let paths = vec![input.source_path.clone(), input.destination_path.clone()];
|
||||
let decision = decide_permission_for_paths(Self::NAME, &paths, settings);
|
||||
if let ToolPermissionDecision::Deny(reason) = decision {
|
||||
return Task::ready(Err(anyhow!("{}", reason)));
|
||||
}
|
||||
|
||||
let dest_decision =
|
||||
decide_permission_for_path(Self::NAME, &input.destination_path, settings);
|
||||
if let ToolPermissionDecision::Deny(reason) = dest_decision {
|
||||
return Task::ready(Err(anyhow!("{}", reason)));
|
||||
}
|
||||
|
||||
let needs_confirmation = matches!(source_decision, ToolPermissionDecision::Confirm)
|
||||
|| matches!(dest_decision, ToolPermissionDecision::Confirm)
|
||||
|| (!settings.always_allow_tool_actions
|
||||
&& matches!(source_decision, ToolPermissionDecision::Allow)
|
||||
&& is_sensitive_settings_path(Path::new(&input.source_path)))
|
||||
|| (!settings.always_allow_tool_actions
|
||||
&& matches!(dest_decision, ToolPermissionDecision::Allow)
|
||||
&& is_sensitive_settings_path(Path::new(&input.destination_path)));
|
||||
let needs_confirmation = matches!(decision, ToolPermissionDecision::Confirm)
|
||||
|| (matches!(decision, ToolPermissionDecision::Allow)
|
||||
&& (is_sensitive_settings_path(Path::new(&input.source_path))
|
||||
|| is_sensitive_settings_path(Path::new(&input.destination_path))));
|
||||
|
||||
let authorize = if needs_confirmation {
|
||||
let src = MarkdownInlineCode(&input.source_path);
|
||||
let dest = MarkdownInlineCode(&input.destination_path);
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: format!("{}\n{}", input.source_path, input.destination_path),
|
||||
input_values: vec![input.source_path.clone(), input.destination_path.clone()],
|
||||
};
|
||||
let title = format!("Copy {src} to {dest}");
|
||||
let sensitive_kind = sensitive_settings_kind(Path::new(&input.source_path))
|
||||
@@ -156,7 +147,7 @@ impl AgentTool for CopyPathTool {
|
||||
anyhow::bail!("Copy cancelled by user");
|
||||
}
|
||||
};
|
||||
let _ = result.with_context(|| {
|
||||
result.with_context(|| {
|
||||
format!(
|
||||
"Copying {} to {}",
|
||||
input.source_path, input.destination_path
|
||||
|
||||
@@ -74,10 +74,7 @@ impl AgentTool for CreateDirectoryTool {
|
||||
let mut decision = decide_permission_for_path(Self::NAME, &input.path, settings);
|
||||
let sensitive_kind = sensitive_settings_kind(Path::new(&input.path));
|
||||
|
||||
if matches!(decision, ToolPermissionDecision::Allow)
|
||||
&& !settings.always_allow_tool_actions
|
||||
&& sensitive_kind.is_some()
|
||||
{
|
||||
if matches!(decision, ToolPermissionDecision::Allow) && sensitive_kind.is_some() {
|
||||
decision = ToolPermissionDecision::Confirm;
|
||||
}
|
||||
|
||||
@@ -95,7 +92,7 @@ impl AgentTool for CreateDirectoryTool {
|
||||
};
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: input.path.clone(),
|
||||
input_values: vec![input.path.clone()],
|
||||
};
|
||||
Some(event_stream.authorize(title, context, cx))
|
||||
}
|
||||
|
||||
@@ -81,7 +81,6 @@ impl AgentTool for DeletePathTool {
|
||||
let mut decision = decide_permission_for_path(Self::NAME, &path, settings);
|
||||
|
||||
if matches!(decision, ToolPermissionDecision::Allow)
|
||||
&& !settings.always_allow_tool_actions
|
||||
&& is_sensitive_settings_path(Path::new(&path))
|
||||
{
|
||||
decision = ToolPermissionDecision::Confirm;
|
||||
@@ -95,7 +94,7 @@ impl AgentTool for DeletePathTool {
|
||||
ToolPermissionDecision::Confirm => {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: path.clone(),
|
||||
input_values: vec![path.clone()],
|
||||
};
|
||||
let title = format!("Delete {}", MarkdownInlineCode(&path));
|
||||
let title = match sensitive_settings_kind(Path::new(&path)) {
|
||||
|
||||
@@ -254,9 +254,7 @@ pub fn authorize_file_edit(
|
||||
|
||||
let explicitly_allowed = matches!(decision, ToolPermissionDecision::Allow);
|
||||
|
||||
if explicitly_allowed
|
||||
&& (settings.always_allow_tool_actions || !is_sensitive_settings_path(path))
|
||||
{
|
||||
if explicitly_allowed && !is_sensitive_settings_path(path) {
|
||||
return Task::ready(Ok(()));
|
||||
}
|
||||
|
||||
@@ -264,7 +262,7 @@ pub fn authorize_file_edit(
|
||||
Some(SensitiveSettingsKind::Local) => {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: tool_name.to_string(),
|
||||
input_value: path_str.to_string(),
|
||||
input_values: vec![path_str.to_string()],
|
||||
};
|
||||
return event_stream.authorize(
|
||||
format!("{} (local settings)", display_description),
|
||||
@@ -275,7 +273,7 @@ pub fn authorize_file_edit(
|
||||
Some(SensitiveSettingsKind::Global) => {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: tool_name.to_string(),
|
||||
input_value: path_str.to_string(),
|
||||
input_values: vec![path_str.to_string()],
|
||||
};
|
||||
return event_stream.authorize(
|
||||
format!("{} (settings)", display_description),
|
||||
@@ -297,7 +295,7 @@ pub fn authorize_file_edit(
|
||||
} else {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: tool_name.to_string(),
|
||||
input_value: path_str.to_string(),
|
||||
input_values: vec![path_str.to_string()],
|
||||
};
|
||||
event_stream.authorize(display_description, context, cx)
|
||||
}
|
||||
@@ -540,7 +538,8 @@ impl AgentTool for EditFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
// If format_on_save is enabled, format the buffer
|
||||
let edit_agent_output = output.await?;
|
||||
|
||||
let format_on_save_enabled = buffer.read_with(cx, |buffer, cx| {
|
||||
let settings = language_settings::language_settings(
|
||||
buffer.language().map(|l| l.name()),
|
||||
@@ -550,8 +549,6 @@ impl AgentTool for EditFileTool {
|
||||
settings.format_on_save != FormatOnSave::Off
|
||||
});
|
||||
|
||||
let edit_agent_output = output.await?;
|
||||
|
||||
if format_on_save_enabled {
|
||||
action_log.update(cx, |log, cx| {
|
||||
log.buffer_edited(buffer.clone(), cx);
|
||||
@@ -1309,15 +1306,17 @@ mod tests {
|
||||
Some("test 4 (local settings)".into())
|
||||
);
|
||||
|
||||
// Test 5: When always_allow_tool_actions is enabled, no confirmation needed
|
||||
// Test 5: When global default is allow, sensitive and outside-project
|
||||
// paths still require confirmation
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = true;
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
// 5.1: .zed/settings.json is a sensitive path — still prompts
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
cx.update(|cx| {
|
||||
let _auth = cx.update(|cx| {
|
||||
tool.authorize(
|
||||
&EditFileToolInput {
|
||||
display_description: "test 5.1".into(),
|
||||
@@ -1327,11 +1326,14 @@ mod tests {
|
||||
&stream_tx,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stream_rx.try_next().is_err());
|
||||
});
|
||||
let event = stream_rx.expect_authorization().await;
|
||||
assert_eq!(
|
||||
event.tool_call.fields.title,
|
||||
Some("test 5.1 (local settings)".into())
|
||||
);
|
||||
|
||||
// 5.2: /etc/hosts is outside the project, but Allow auto-approves
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
cx.update(|cx| {
|
||||
tool.authorize(
|
||||
@@ -1347,6 +1349,46 @@ mod tests {
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stream_rx.try_next().is_err());
|
||||
|
||||
// 5.3: Normal in-project path with allow — no confirmation needed
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
cx.update(|cx| {
|
||||
tool.authorize(
|
||||
&EditFileToolInput {
|
||||
display_description: "test 5.3".into(),
|
||||
path: "root/src/main.rs".into(),
|
||||
mode: EditFileMode::Edit,
|
||||
},
|
||||
&stream_tx,
|
||||
cx,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(stream_rx.try_next().is_err());
|
||||
|
||||
// 5.4: With Confirm default, non-project paths still prompt
|
||||
cx.update(|cx| {
|
||||
let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
|
||||
agent_settings::AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
let (stream_tx, mut stream_rx) = ToolCallEventStream::test();
|
||||
let _auth = cx.update(|cx| {
|
||||
tool.authorize(
|
||||
&EditFileToolInput {
|
||||
display_description: "test 5.4".into(),
|
||||
path: "/etc/hosts".into(),
|
||||
mode: EditFileMode::Edit,
|
||||
},
|
||||
&stream_tx,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
let event = stream_rx.expect_authorization().await;
|
||||
assert_eq!(event.tool_call.fields.title, Some("test 5.4".into()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -146,7 +146,8 @@ impl AgentTool for FetchTool {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Self::Output>> {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let decision = decide_permission_from_settings(Self::NAME, &input.url, settings);
|
||||
let decision =
|
||||
decide_permission_from_settings(Self::NAME, std::slice::from_ref(&input.url), settings);
|
||||
|
||||
let authorize = match decision {
|
||||
ToolPermissionDecision::Allow => None,
|
||||
@@ -156,7 +157,7 @@ impl AgentTool for FetchTool {
|
||||
ToolPermissionDecision::Confirm => {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: input.url.clone(),
|
||||
input_values: vec![input.url.clone()],
|
||||
};
|
||||
Some(event_stream.authorize(
|
||||
format!("Fetch {}", MarkdownInlineCode(&input.url)),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use super::edit_file_tool::{
|
||||
SensitiveSettingsKind, is_sensitive_settings_path, sensitive_settings_kind,
|
||||
};
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_paths};
|
||||
use agent_client_protocol::ToolKind;
|
||||
use agent_settings::AgentSettings;
|
||||
use anyhow::{Context as _, Result, anyhow};
|
||||
@@ -98,32 +98,23 @@ impl AgentTool for MovePathTool {
|
||||
) -> Task<Result<Self::Output>> {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
|
||||
let source_decision = decide_permission_for_path(Self::NAME, &input.source_path, settings);
|
||||
if let ToolPermissionDecision::Deny(reason) = source_decision {
|
||||
let paths = vec![input.source_path.clone(), input.destination_path.clone()];
|
||||
let decision = decide_permission_for_paths(Self::NAME, &paths, settings);
|
||||
if let ToolPermissionDecision::Deny(reason) = decision {
|
||||
return Task::ready(Err(anyhow!("{}", reason)));
|
||||
}
|
||||
|
||||
let dest_decision =
|
||||
decide_permission_for_path(Self::NAME, &input.destination_path, settings);
|
||||
if let ToolPermissionDecision::Deny(reason) = dest_decision {
|
||||
return Task::ready(Err(anyhow!("{}", reason)));
|
||||
}
|
||||
|
||||
let needs_confirmation = matches!(source_decision, ToolPermissionDecision::Confirm)
|
||||
|| matches!(dest_decision, ToolPermissionDecision::Confirm)
|
||||
|| (!settings.always_allow_tool_actions
|
||||
&& matches!(source_decision, ToolPermissionDecision::Allow)
|
||||
&& is_sensitive_settings_path(Path::new(&input.source_path)))
|
||||
|| (!settings.always_allow_tool_actions
|
||||
&& matches!(dest_decision, ToolPermissionDecision::Allow)
|
||||
&& is_sensitive_settings_path(Path::new(&input.destination_path)));
|
||||
let needs_confirmation = matches!(decision, ToolPermissionDecision::Confirm)
|
||||
|| (matches!(decision, ToolPermissionDecision::Allow)
|
||||
&& (is_sensitive_settings_path(Path::new(&input.source_path))
|
||||
|| is_sensitive_settings_path(Path::new(&input.destination_path))));
|
||||
|
||||
let authorize = if needs_confirmation {
|
||||
let src = MarkdownInlineCode(&input.source_path);
|
||||
let dest = MarkdownInlineCode(&input.destination_path);
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: format!("{}\n{}", input.source_path, input.destination_path),
|
||||
input_values: vec![input.source_path.clone(), input.destination_path.clone()],
|
||||
};
|
||||
let title = format!("Move {src} to {dest}");
|
||||
let settings_kind = sensitive_settings_kind(Path::new(&input.source_path))
|
||||
@@ -169,7 +160,7 @@ impl AgentTool for MovePathTool {
|
||||
anyhow::bail!("Move cancelled by user");
|
||||
}
|
||||
};
|
||||
let _ = result.with_context(|| {
|
||||
result.with_context(|| {
|
||||
format!("Moving {} to {}", input.source_path, input.destination_path)
|
||||
})?;
|
||||
Ok(format!(
|
||||
|
||||
@@ -66,7 +66,7 @@ impl AgentTool for OpenTool {
|
||||
let abs_path = to_absolute_path(&input.path_or_url, self.project.clone(), cx);
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: input.path_or_url.clone(),
|
||||
input_values: vec![input.path_or_url.clone()],
|
||||
};
|
||||
let authorize =
|
||||
event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx);
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
use super::edit_file_tool::{
|
||||
SensitiveSettingsKind, is_sensitive_settings_path, sensitive_settings_kind,
|
||||
};
|
||||
use agent_client_protocol as acp;
|
||||
use agent_settings::AgentSettings;
|
||||
use anyhow::Result;
|
||||
use collections::FxHashSet;
|
||||
use futures::FutureExt as _;
|
||||
@@ -7,10 +11,12 @@ use language::Buffer;
|
||||
use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use settings::Settings;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use util::markdown::MarkdownInlineCode;
|
||||
|
||||
use crate::{AgentTool, ToolCallEventStream};
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
|
||||
|
||||
/// Discards unsaved changes in open buffers by reloading file contents from disk.
|
||||
///
|
||||
@@ -63,10 +69,73 @@ impl AgentTool for RestoreFileFromDiskTool {
|
||||
event_stream: ToolCallEventStream,
|
||||
cx: &mut App,
|
||||
) -> Task<Result<String>> {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let mut confirmation_paths: Vec<String> = Vec::new();
|
||||
|
||||
for path in &input.paths {
|
||||
let path_str = path.to_string_lossy();
|
||||
let decision = decide_permission_for_path(Self::NAME, &path_str, settings);
|
||||
match decision {
|
||||
ToolPermissionDecision::Allow => {
|
||||
if is_sensitive_settings_path(Path::new(&*path_str)) {
|
||||
confirmation_paths.push(path_str.to_string());
|
||||
}
|
||||
}
|
||||
ToolPermissionDecision::Deny(reason) => {
|
||||
return Task::ready(Err(anyhow::anyhow!("{}", reason)));
|
||||
}
|
||||
ToolPermissionDecision::Confirm => {
|
||||
confirmation_paths.push(path_str.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let authorize = if !confirmation_paths.is_empty() {
|
||||
let title = if confirmation_paths.len() == 1 {
|
||||
format!(
|
||||
"Restore {} from disk",
|
||||
MarkdownInlineCode(&confirmation_paths[0])
|
||||
)
|
||||
} else {
|
||||
let paths: Vec<_> = confirmation_paths
|
||||
.iter()
|
||||
.take(3)
|
||||
.map(|p| p.as_str())
|
||||
.collect();
|
||||
if confirmation_paths.len() > 3 {
|
||||
format!(
|
||||
"Restore {}, and {} more from disk",
|
||||
paths.join(", "),
|
||||
confirmation_paths.len() - 3
|
||||
)
|
||||
} else {
|
||||
format!("Restore {} from disk", paths.join(", "))
|
||||
}
|
||||
};
|
||||
let sensitive_kind = confirmation_paths
|
||||
.iter()
|
||||
.find_map(|p| sensitive_settings_kind(Path::new(p)));
|
||||
let title = match sensitive_kind {
|
||||
Some(SensitiveSettingsKind::Local) => format!("{title} (local settings)"),
|
||||
Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
|
||||
None => title,
|
||||
};
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_values: confirmation_paths,
|
||||
};
|
||||
Some(event_stream.authorize(title, context, cx))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let project = self.project.clone();
|
||||
let input_paths = input.paths;
|
||||
|
||||
cx.spawn(async move |cx| {
|
||||
if let Some(authorize) = authorize {
|
||||
authorize.await?;
|
||||
}
|
||||
let mut buffers_to_reload: FxHashSet<Entity<Buffer>> = FxHashSet::default();
|
||||
|
||||
let mut restored_paths: Vec<PathBuf> = Vec::new();
|
||||
@@ -190,6 +259,11 @@ mod tests {
|
||||
let settings_store = SettingsStore::test(cx);
|
||||
cx.set_global(settings_store);
|
||||
});
|
||||
cx.update(|cx| {
|
||||
let mut settings = AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
||||
AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
|
||||
@@ -9,7 +9,7 @@ use project::Project;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use settings::Settings;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use util::markdown::MarkdownInlineCode;
|
||||
|
||||
@@ -18,24 +18,6 @@ use super::edit_file_tool::{
|
||||
};
|
||||
use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
|
||||
|
||||
fn common_parent_for_paths(paths: &[String]) -> Option<PathBuf> {
|
||||
let first = paths.first()?;
|
||||
let mut common: Vec<Component<'_>> = Path::new(first).parent()?.components().collect();
|
||||
for path in &paths[1..] {
|
||||
let parent: Vec<Component<'_>> = Path::new(path).parent()?.components().collect();
|
||||
let prefix_len = common
|
||||
.iter()
|
||||
.zip(parent.iter())
|
||||
.take_while(|(a, b)| a == b)
|
||||
.count();
|
||||
common.truncate(prefix_len);
|
||||
}
|
||||
if common.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(common.iter().collect())
|
||||
}
|
||||
|
||||
/// Saves files that have unsaved changes.
|
||||
///
|
||||
/// Use this tool when you need to edit files but they have unsaved changes that must be saved first.
|
||||
@@ -92,9 +74,7 @@ impl AgentTool for SaveFileTool {
|
||||
let decision = decide_permission_for_path(Self::NAME, &path_str, settings);
|
||||
match decision {
|
||||
ToolPermissionDecision::Allow => {
|
||||
if !settings.always_allow_tool_actions
|
||||
&& is_sensitive_settings_path(Path::new(&*path_str))
|
||||
{
|
||||
if is_sensitive_settings_path(Path::new(&*path_str)) {
|
||||
confirmation_paths.push(path_str.to_string());
|
||||
}
|
||||
}
|
||||
@@ -134,16 +114,9 @@ impl AgentTool for SaveFileTool {
|
||||
Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
|
||||
None => title,
|
||||
};
|
||||
let input_value = if confirmation_paths.len() == 1 {
|
||||
confirmation_paths[0].clone()
|
||||
} else {
|
||||
common_parent_for_paths(&confirmation_paths)
|
||||
.map(|parent| format!("{}/_", parent.display()))
|
||||
.unwrap_or_else(|| confirmation_paths[0].clone())
|
||||
};
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value,
|
||||
input_values: confirmation_paths.clone(),
|
||||
};
|
||||
Some(event_stream.authorize(title, context, cx))
|
||||
} else {
|
||||
@@ -160,11 +133,10 @@ impl AgentTool for SaveFileTool {
|
||||
|
||||
let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
|
||||
|
||||
let mut saved_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut dirty_count: usize = 0;
|
||||
let mut clean_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut not_found_paths: Vec<PathBuf> = Vec::new();
|
||||
let mut open_errors: Vec<(PathBuf, String)> = Vec::new();
|
||||
let dirty_check_errors: Vec<(PathBuf, String)> = Vec::new();
|
||||
let mut save_errors: Vec<(String, String)> = Vec::new();
|
||||
|
||||
for path in input_paths {
|
||||
@@ -197,7 +169,7 @@ impl AgentTool for SaveFileTool {
|
||||
|
||||
if is_dirty {
|
||||
buffers_to_save.insert(buffer);
|
||||
saved_paths.push(path);
|
||||
dirty_count += 1;
|
||||
} else {
|
||||
clean_paths.push(path);
|
||||
}
|
||||
@@ -229,8 +201,9 @@ impl AgentTool for SaveFileTool {
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
|
||||
if !saved_paths.is_empty() {
|
||||
lines.push(format!("Saved {} file(s).", saved_paths.len()));
|
||||
let successful_saves = dirty_count.saturating_sub(save_errors.len());
|
||||
if successful_saves > 0 {
|
||||
lines.push(format!("Saved {} file(s).", successful_saves));
|
||||
}
|
||||
if !clean_paths.is_empty() {
|
||||
lines.push(format!("{} clean.", clean_paths.len()));
|
||||
@@ -248,15 +221,6 @@ impl AgentTool for SaveFileTool {
|
||||
lines.push(format!("- {}: {}", path.display(), error));
|
||||
}
|
||||
}
|
||||
if !dirty_check_errors.is_empty() {
|
||||
lines.push(format!(
|
||||
"Dirty check failed ({}):",
|
||||
dirty_check_errors.len()
|
||||
));
|
||||
for (path, error) in &dirty_check_errors {
|
||||
lines.push(format!("- {}: {}", path.display(), error));
|
||||
}
|
||||
}
|
||||
if !save_errors.is_empty() {
|
||||
lines.push(format!("Save failed ({}):", save_errors.len()));
|
||||
for (path, error) in &save_errors {
|
||||
@@ -290,7 +254,7 @@ mod tests {
|
||||
});
|
||||
cx.update(|cx| {
|
||||
let mut settings = AgentSettings::get_global(cx).clone();
|
||||
settings.always_allow_tool_actions = true;
|
||||
settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
|
||||
AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -165,6 +165,15 @@ impl StreamingEditFileTool {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_thread(&self, new_thread: WeakEntity<Thread>) -> Self {
|
||||
Self {
|
||||
project: self.project.clone(),
|
||||
thread: new_thread,
|
||||
language_registry: self.language_registry.clone(),
|
||||
templates: self.templates.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn authorize(
|
||||
&self,
|
||||
input: &StreamingEditFileToolInput,
|
||||
|
||||
@@ -95,7 +95,11 @@ impl AgentTool for TerminalTool {
|
||||
};
|
||||
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let decision = decide_permission_from_settings(Self::NAME, &input.command, settings);
|
||||
let decision = decide_permission_from_settings(
|
||||
Self::NAME,
|
||||
std::slice::from_ref(&input.command),
|
||||
settings,
|
||||
);
|
||||
|
||||
let authorize = match decision {
|
||||
ToolPermissionDecision::Allow => None,
|
||||
@@ -105,7 +109,7 @@ impl AgentTool for TerminalTool {
|
||||
ToolPermissionDecision::Confirm => {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: input.command.clone(),
|
||||
input_values: vec![input.command.clone()],
|
||||
};
|
||||
Some(event_stream.authorize(self.initial_title(Ok(input.clone()), cx), context, cx))
|
||||
}
|
||||
|
||||
@@ -72,7 +72,11 @@ impl AgentTool for WebSearchTool {
|
||||
cx: &mut App,
|
||||
) -> Task<Result<Self::Output>> {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let decision = decide_permission_from_settings(Self::NAME, &input.query, settings);
|
||||
let decision = decide_permission_from_settings(
|
||||
Self::NAME,
|
||||
std::slice::from_ref(&input.query),
|
||||
settings,
|
||||
);
|
||||
|
||||
let authorize = match decision {
|
||||
ToolPermissionDecision::Allow => None,
|
||||
@@ -82,7 +86,7 @@ impl AgentTool for WebSearchTool {
|
||||
ToolPermissionDecision::Confirm => {
|
||||
let context = crate::ToolPermissionContext {
|
||||
tool_name: Self::NAME.to_string(),
|
||||
input_value: input.query.clone(),
|
||||
input_values: vec![input.query.clone()],
|
||||
};
|
||||
Some(event_stream.authorize(
|
||||
format!("Search the web for {}", MarkdownInlineCode(&input.query)),
|
||||
|
||||
@@ -1147,14 +1147,12 @@ impl acp::Client for ClientDelegate {
|
||||
&self,
|
||||
arguments: acp::RequestPermissionRequest,
|
||||
) -> Result<acp::RequestPermissionResponse, acp::Error> {
|
||||
let respect_always_allow_setting;
|
||||
let thread;
|
||||
{
|
||||
let sessions_ref = self.sessions.borrow();
|
||||
let session = sessions_ref
|
||||
.get(&arguments.session_id)
|
||||
.context("Failed to get session")?;
|
||||
respect_always_allow_setting = session.session_modes.is_none();
|
||||
thread = session.thread.clone();
|
||||
}
|
||||
|
||||
@@ -1164,7 +1162,6 @@ impl acp::Client for ClientDelegate {
|
||||
thread.request_tool_call_authorization(
|
||||
arguments.tool_call,
|
||||
acp_thread::PermissionOptions::Flat(arguments.options),
|
||||
respect_always_allow_setting,
|
||||
cx,
|
||||
)
|
||||
})??;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod agent_profile;
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::path::{Component, Path};
|
||||
use std::sync::{Arc, LazyLock};
|
||||
|
||||
use agent_client_protocol::ModelId;
|
||||
use collections::{HashSet, IndexMap};
|
||||
@@ -38,7 +39,7 @@ pub struct AgentSettings {
|
||||
pub default_profile: AgentProfileId,
|
||||
pub default_view: DefaultAgentView,
|
||||
pub profiles: IndexMap<AgentProfileId, AgentProfileSettings>,
|
||||
pub always_allow_tool_actions: bool,
|
||||
|
||||
pub notify_when_agent_waiting: NotifyWhenAgentWaiting,
|
||||
pub play_sound_when_agent_done: bool,
|
||||
pub single_file_review: bool,
|
||||
@@ -111,6 +112,8 @@ impl Default for AgentProfileId {
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ToolPermissions {
|
||||
/// Global default permission when no tool-specific rules or patterns match.
|
||||
pub default: ToolPermissionMode,
|
||||
pub tools: collections::HashMap<Arc<str>, ToolRules>,
|
||||
}
|
||||
|
||||
@@ -142,9 +145,9 @@ pub struct InvalidRegexPattern {
|
||||
pub error: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ToolRules {
|
||||
pub default_mode: ToolPermissionMode,
|
||||
pub default: Option<ToolPermissionMode>,
|
||||
pub always_allow: Vec<CompiledRegex>,
|
||||
pub always_deny: Vec<CompiledRegex>,
|
||||
pub always_confirm: Vec<CompiledRegex>,
|
||||
@@ -152,18 +155,6 @@ pub struct ToolRules {
|
||||
pub invalid_patterns: Vec<InvalidRegexPattern>,
|
||||
}
|
||||
|
||||
impl Default for ToolRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
default_mode: ToolPermissionMode::Confirm,
|
||||
always_allow: Vec::new(),
|
||||
always_deny: Vec::new(),
|
||||
always_confirm: Vec::new(),
|
||||
invalid_patterns: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CompiledRegex {
|
||||
pub pattern: String,
|
||||
@@ -201,6 +192,214 @@ impl CompiledRegex {
|
||||
}
|
||||
}
|
||||
|
||||
pub const HARDCODED_SECURITY_DENIAL_MESSAGE: &str = "Blocked by built-in security rule. This operation is considered too \
|
||||
harmful to be allowed, and cannot be overridden by settings.";
|
||||
|
||||
/// Security rules that are always enforced and cannot be overridden by any setting.
|
||||
/// These protect against catastrophic operations like wiping filesystems.
|
||||
pub struct HardcodedSecurityRules {
|
||||
pub terminal_deny: Vec<CompiledRegex>,
|
||||
}
|
||||
|
||||
pub static HARDCODED_SECURITY_RULES: LazyLock<HardcodedSecurityRules> = LazyLock::new(|| {
|
||||
const FLAGS: &str = r"(--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?\s+|-[a-zA-Z]+\s+)*";
|
||||
const TRAILING_FLAGS: &str = r"(\s+--[a-zA-Z0-9][-a-zA-Z0-9_]*(=[^\s]*)?|\s+-[a-zA-Z]+)*\s*";
|
||||
|
||||
HardcodedSecurityRules {
|
||||
terminal_deny: vec![
|
||||
// Recursive deletion of root - "rm -rf /", "rm -rf /*"
|
||||
CompiledRegex::new(
|
||||
&format!(r"\brm\s+{FLAGS}(--\s+)?/\*?{TRAILING_FLAGS}$"),
|
||||
false,
|
||||
)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of home via tilde - "rm -rf ~", "rm -rf ~/"
|
||||
CompiledRegex::new(
|
||||
&format!(r"\brm\s+{FLAGS}(--\s+)?~/?\*?{TRAILING_FLAGS}$"),
|
||||
false,
|
||||
)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of home via env var - "rm -rf $HOME", "rm -rf ${HOME}"
|
||||
CompiledRegex::new(
|
||||
&format!(r"\brm\s+{FLAGS}(--\s+)?(\$HOME|\$\{{HOME\}})/?(\*)?{TRAILING_FLAGS}$"),
|
||||
false,
|
||||
)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of current directory - "rm -rf .", "rm -rf ./"
|
||||
CompiledRegex::new(
|
||||
&format!(r"\brm\s+{FLAGS}(--\s+)?\./?\*?{TRAILING_FLAGS}$"),
|
||||
false,
|
||||
)
|
||||
.expect("hardcoded regex should compile"),
|
||||
// Recursive deletion of parent directory - "rm -rf ..", "rm -rf ../"
|
||||
CompiledRegex::new(
|
||||
&format!(r"\brm\s+{FLAGS}(--\s+)?\.\./?\*?{TRAILING_FLAGS}$"),
|
||||
false,
|
||||
)
|
||||
.expect("hardcoded regex should compile"),
|
||||
],
|
||||
}
|
||||
});
|
||||
|
||||
/// Checks if input matches any hardcoded security rules that cannot be bypassed.
|
||||
/// Returns the denial reason string if blocked, None otherwise.
|
||||
///
|
||||
/// `terminal_tool_name` should be the tool name used for the terminal tool
|
||||
/// (e.g. `"terminal"`). `extracted_commands` can optionally provide parsed
|
||||
/// sub-commands for chained command checking; callers with access to a shell
|
||||
/// parser should extract sub-commands and pass them here.
|
||||
pub fn check_hardcoded_security_rules(
|
||||
tool_name: &str,
|
||||
terminal_tool_name: &str,
|
||||
input: &str,
|
||||
extracted_commands: Option<&[String]>,
|
||||
) -> Option<String> {
|
||||
if tool_name != terminal_tool_name {
|
||||
return None;
|
||||
}
|
||||
|
||||
let rules = &*HARDCODED_SECURITY_RULES;
|
||||
let terminal_patterns = &rules.terminal_deny;
|
||||
|
||||
if matches_hardcoded_patterns(input, terminal_patterns) {
|
||||
return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
|
||||
}
|
||||
|
||||
if let Some(commands) = extracted_commands {
|
||||
for command in commands {
|
||||
if matches_hardcoded_patterns(command, terminal_patterns) {
|
||||
return Some(HARDCODED_SECURITY_DENIAL_MESSAGE.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn matches_hardcoded_patterns(command: &str, patterns: &[CompiledRegex]) -> bool {
|
||||
for pattern in patterns {
|
||||
if pattern.is_match(command) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
for expanded in expand_rm_to_single_path_commands(command) {
|
||||
for pattern in patterns {
|
||||
if pattern.is_match(&expanded) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn expand_rm_to_single_path_commands(command: &str) -> Vec<String> {
|
||||
let trimmed = command.trim();
|
||||
|
||||
let first_token = trimmed.split_whitespace().next();
|
||||
if !first_token.is_some_and(|t| t.eq_ignore_ascii_case("rm")) {
|
||||
return vec![];
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
let mut flags = Vec::new();
|
||||
let mut paths = Vec::new();
|
||||
let mut past_double_dash = false;
|
||||
|
||||
for part in parts.iter().skip(1) {
|
||||
if !past_double_dash && *part == "--" {
|
||||
past_double_dash = true;
|
||||
flags.push(*part);
|
||||
continue;
|
||||
}
|
||||
if !past_double_dash && part.starts_with('-') {
|
||||
flags.push(*part);
|
||||
} else {
|
||||
paths.push(*part);
|
||||
}
|
||||
}
|
||||
|
||||
let flags_str = if flags.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{} ", flags.join(" "))
|
||||
};
|
||||
|
||||
let mut results = Vec::new();
|
||||
for path in &paths {
|
||||
if path.starts_with('$') {
|
||||
let home_prefix = if path.starts_with("${HOME}") {
|
||||
Some("${HOME}")
|
||||
} else if path.starts_with("$HOME") {
|
||||
Some("$HOME")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(prefix) = home_prefix {
|
||||
let suffix = &path[prefix.len()..];
|
||||
if suffix.is_empty() {
|
||||
results.push(format!("rm {flags_str}{path}"));
|
||||
} else if suffix.starts_with('/') {
|
||||
let normalized_suffix = normalize_path(suffix);
|
||||
let reconstructed = if normalized_suffix == "/" {
|
||||
prefix.to_string()
|
||||
} else {
|
||||
format!("{prefix}{normalized_suffix}")
|
||||
};
|
||||
results.push(format!("rm {flags_str}{reconstructed}"));
|
||||
} else {
|
||||
results.push(format!("rm {flags_str}{path}"));
|
||||
}
|
||||
} else {
|
||||
results.push(format!("rm {flags_str}{path}"));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut normalized = normalize_path(path);
|
||||
if normalized.is_empty() && !Path::new(path).has_root() {
|
||||
normalized = ".".to_string();
|
||||
}
|
||||
|
||||
results.push(format!("rm {flags_str}{normalized}"));
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
pub fn normalize_path(raw: &str) -> String {
|
||||
let is_absolute = Path::new(raw).has_root();
|
||||
let mut components: Vec<&str> = Vec::new();
|
||||
for component in Path::new(raw).components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::ParentDir => {
|
||||
if components.last() == Some(&"..") {
|
||||
components.push("..");
|
||||
} else if !components.is_empty() {
|
||||
components.pop();
|
||||
} else if !is_absolute {
|
||||
components.push("..");
|
||||
}
|
||||
}
|
||||
Component::Normal(segment) => {
|
||||
if let Some(s) = segment.to_str() {
|
||||
components.push(s);
|
||||
}
|
||||
}
|
||||
Component::RootDir | Component::Prefix(_) => {}
|
||||
}
|
||||
}
|
||||
let joined = components.join("/");
|
||||
if is_absolute {
|
||||
format!("/{joined}")
|
||||
} else {
|
||||
joined
|
||||
}
|
||||
}
|
||||
|
||||
impl Settings for AgentSettings {
|
||||
fn from_settings(content: &settings::SettingsContent) -> Self {
|
||||
let agent = content.agent.clone().unwrap();
|
||||
@@ -228,7 +427,7 @@ impl Settings for AgentSettings {
|
||||
.into_iter()
|
||||
.map(|(key, val)| (AgentProfileId(key), val.into()))
|
||||
.collect(),
|
||||
always_allow_tool_actions: agent.always_allow_tool_actions.unwrap(),
|
||||
|
||||
notify_when_agent_waiting: agent.notify_when_agent_waiting.unwrap(),
|
||||
play_sound_when_agent_done: agent.play_sound_when_agent_done.unwrap(),
|
||||
single_file_review: agent.single_file_review.unwrap(),
|
||||
@@ -290,7 +489,8 @@ fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -
|
||||
}
|
||||
|
||||
let rules = ToolRules {
|
||||
default_mode: rules_content.default_mode.unwrap_or_default(),
|
||||
// Preserve tool-specific default; None means fall back to global default at decision time
|
||||
default: rules_content.default,
|
||||
always_allow,
|
||||
always_deny,
|
||||
always_confirm,
|
||||
@@ -300,7 +500,10 @@ fn compile_tool_permissions(content: Option<settings::ToolPermissionsContent>) -
|
||||
})
|
||||
.collect();
|
||||
|
||||
ToolPermissions { tools }
|
||||
ToolPermissions {
|
||||
default: content.default.unwrap_or_default(),
|
||||
tools,
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_regex_rules(
|
||||
@@ -311,6 +514,14 @@ fn compile_regex_rules(
|
||||
let mut errors = Vec::new();
|
||||
|
||||
for rule in rules {
|
||||
if rule.pattern.is_empty() {
|
||||
errors.push(InvalidRegexPattern {
|
||||
pattern: rule.pattern,
|
||||
rule_type: rule_type.to_string(),
|
||||
error: "empty regex patterns are not allowed".to_string(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
let case_sensitive = rule.case_sensitive.unwrap_or(false);
|
||||
match CompiledRegex::try_new(&rule.pattern, case_sensitive) {
|
||||
Ok(regex) => compiled.push(regex),
|
||||
@@ -331,6 +542,7 @@ fn compile_regex_rules(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
use settings::ToolPermissionMode;
|
||||
use settings::ToolPermissionsContent;
|
||||
|
||||
#[test]
|
||||
@@ -359,7 +571,7 @@ mod tests {
|
||||
let json = json!({
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default_mode": "allow",
|
||||
"default": "allow",
|
||||
"always_deny": [
|
||||
{ "pattern": "rm\\s+-rf" }
|
||||
],
|
||||
@@ -374,7 +586,7 @@ mod tests {
|
||||
let permissions = compile_tool_permissions(Some(content));
|
||||
|
||||
let terminal_rules = permissions.tools.get("terminal").unwrap();
|
||||
assert_eq!(terminal_rules.default_mode, ToolPermissionMode::Allow);
|
||||
assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
|
||||
assert_eq!(terminal_rules.always_deny.len(), 1);
|
||||
assert_eq!(terminal_rules.always_allow.len(), 1);
|
||||
assert!(terminal_rules.always_deny[0].is_match("rm -rf /"));
|
||||
@@ -382,11 +594,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_rules_default_mode() {
|
||||
fn test_tool_rules_default() {
|
||||
let json = json!({
|
||||
"tools": {
|
||||
"edit_file": {
|
||||
"default_mode": "deny"
|
||||
"default": "deny"
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -395,19 +607,20 @@ mod tests {
|
||||
let permissions = compile_tool_permissions(Some(content));
|
||||
|
||||
let rules = permissions.tools.get("edit_file").unwrap();
|
||||
assert_eq!(rules.default_mode, ToolPermissionMode::Deny);
|
||||
assert_eq!(rules.default, Some(ToolPermissionMode::Deny));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_permissions_empty() {
|
||||
let permissions = compile_tool_permissions(None);
|
||||
assert!(permissions.tools.is_empty());
|
||||
assert_eq!(permissions.default, ToolPermissionMode::Confirm);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_rules_default_returns_confirm() {
|
||||
let default_rules = ToolRules::default();
|
||||
assert_eq!(default_rules.default_mode, ToolPermissionMode::Confirm);
|
||||
assert_eq!(default_rules.default, None);
|
||||
assert!(default_rules.always_allow.is_empty());
|
||||
assert!(default_rules.always_deny.is_empty());
|
||||
assert!(default_rules.always_confirm.is_empty());
|
||||
@@ -418,15 +631,15 @@ mod tests {
|
||||
let json = json!({
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default_mode": "allow",
|
||||
"default": "allow",
|
||||
"always_deny": [{ "pattern": "rm\\s+-rf" }]
|
||||
},
|
||||
"edit_file": {
|
||||
"default_mode": "confirm",
|
||||
"default": "confirm",
|
||||
"always_deny": [{ "pattern": "\\.env$" }]
|
||||
},
|
||||
"delete_path": {
|
||||
"default_mode": "deny"
|
||||
"default": "deny"
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -437,15 +650,15 @@ mod tests {
|
||||
assert_eq!(permissions.tools.len(), 3);
|
||||
|
||||
let terminal = permissions.tools.get("terminal").unwrap();
|
||||
assert_eq!(terminal.default_mode, ToolPermissionMode::Allow);
|
||||
assert_eq!(terminal.default, Some(ToolPermissionMode::Allow));
|
||||
assert_eq!(terminal.always_deny.len(), 1);
|
||||
|
||||
let edit_file = permissions.tools.get("edit_file").unwrap();
|
||||
assert_eq!(edit_file.default_mode, ToolPermissionMode::Confirm);
|
||||
assert_eq!(edit_file.default, Some(ToolPermissionMode::Confirm));
|
||||
assert!(edit_file.always_deny[0].is_match("secrets.env"));
|
||||
|
||||
let delete_path = permissions.tools.get("delete_path").unwrap();
|
||||
assert_eq!(delete_path.default_mode, ToolPermissionMode::Deny);
|
||||
assert_eq!(delete_path.default, Some(ToolPermissionMode::Deny));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -526,7 +739,7 @@ mod tests {
|
||||
let json = json!({
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default_mode": "allow",
|
||||
"default": "allow",
|
||||
"always_deny": [{ "pattern": "dangerous" }],
|
||||
"always_confirm": [{ "pattern": "dangerous" }],
|
||||
"always_allow": [{ "pattern": "dangerous" }]
|
||||
@@ -557,7 +770,7 @@ mod tests {
|
||||
let json = json!({
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default_mode": "allow",
|
||||
"default": "allow",
|
||||
"always_confirm": [{ "pattern": "risky" }],
|
||||
"always_allow": [{ "pattern": "risky" }]
|
||||
}
|
||||
@@ -661,7 +874,7 @@ mod tests {
|
||||
let json = json!({
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default_mode": "allow"
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -722,11 +935,86 @@ mod tests {
|
||||
"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"
|
||||
terminal.default, None,
|
||||
"default should be None when not specified"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_regex_pattern_is_invalid() {
|
||||
let json = json!({
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"always_allow": [
|
||||
{ "pattern": "" }
|
||||
],
|
||||
"always_deny": [
|
||||
{ "case_sensitive": true }
|
||||
],
|
||||
"always_confirm": [
|
||||
{ "pattern": "" },
|
||||
{ "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_allow.len(), 0);
|
||||
assert_eq!(terminal.always_deny.len(), 0);
|
||||
assert_eq!(terminal.always_confirm.len(), 1);
|
||||
assert!(terminal.always_confirm[0].is_match("valid_pattern"));
|
||||
|
||||
assert_eq!(terminal.invalid_patterns.len(), 3);
|
||||
for invalid in &terminal.invalid_patterns {
|
||||
assert_eq!(invalid.pattern, "");
|
||||
assert!(invalid.error.contains("empty"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_json_tool_permissions_parse() {
|
||||
let default_json = include_str!("../../../assets/settings/default.json");
|
||||
let value: serde_json_lenient::Value = serde_json_lenient::from_str(default_json).unwrap();
|
||||
let agent = value
|
||||
.get("agent")
|
||||
.expect("default.json should have 'agent' key");
|
||||
let tool_permissions_value = agent
|
||||
.get("tool_permissions")
|
||||
.expect("agent should have 'tool_permissions' key");
|
||||
|
||||
let content: ToolPermissionsContent =
|
||||
serde_json_lenient::from_value(tool_permissions_value.clone()).unwrap();
|
||||
let permissions = compile_tool_permissions(Some(content));
|
||||
|
||||
assert_eq!(permissions.default, ToolPermissionMode::Confirm);
|
||||
|
||||
assert!(
|
||||
permissions.tools.is_empty(),
|
||||
"default.json should not have any active tool-specific rules, found: {:?}",
|
||||
permissions.tools.keys().collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tool_permissions_explicit_global_default() {
|
||||
let json_allow = json!({
|
||||
"default": "allow"
|
||||
});
|
||||
let content: ToolPermissionsContent = serde_json::from_value(json_allow).unwrap();
|
||||
let permissions = compile_tool_permissions(Some(content));
|
||||
assert_eq!(permissions.default, ToolPermissionMode::Allow);
|
||||
|
||||
let json_deny = json!({
|
||||
"default": "deny"
|
||||
});
|
||||
let content: ToolPermissionsContent = serde_json::from_value(json_deny).unwrap();
|
||||
let permissions = compile_tool_permissions(Some(content));
|
||||
assert_eq!(permissions.default, ToolPermissionMode::Deny);
|
||||
}
|
||||
}
|
||||
@@ -4576,9 +4576,11 @@ pub(crate) mod tests {
|
||||
let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build --release`")
|
||||
.kind(acp::ToolKind::Edit);
|
||||
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
|
||||
.build_permission_options();
|
||||
let permission_options = ToolPermissionContext::new(
|
||||
TerminalTool::NAME,
|
||||
vec!["cargo build --release".to_string()],
|
||||
)
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||
@@ -4684,8 +4686,9 @@ pub(crate) mod tests {
|
||||
let tool_call = acp::ToolCall::new(tool_call_id.clone(), "Edit `src/main.rs`")
|
||||
.kind(acp::ToolKind::Edit);
|
||||
|
||||
let permission_options = ToolPermissionContext::new(EditFileTool::NAME, "src/main.rs")
|
||||
.build_permission_options();
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(EditFileTool::NAME, vec!["src/main.rs".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||
@@ -4772,7 +4775,7 @@ pub(crate) mod tests {
|
||||
.kind(acp::ToolKind::Fetch);
|
||||
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(FetchTool::NAME, "https://docs.rs/gpui")
|
||||
ToolPermissionContext::new(FetchTool::NAME, vec!["https://docs.rs/gpui".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
@@ -4860,9 +4863,11 @@ pub(crate) mod tests {
|
||||
.kind(acp::ToolKind::Edit);
|
||||
|
||||
// No pattern button since ./deploy.sh doesn't match the alphanumeric pattern
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "./deploy.sh --production")
|
||||
.build_permission_options();
|
||||
let permission_options = ToolPermissionContext::new(
|
||||
TerminalTool::NAME,
|
||||
vec!["./deploy.sh --production".to_string()],
|
||||
)
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||
@@ -4960,7 +4965,8 @@ pub(crate) mod tests {
|
||||
acp::ToolCall::new(tool_call_id.clone(), "Run `cargo test`").kind(acp::ToolKind::Edit);
|
||||
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "cargo test").build_permission_options();
|
||||
ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo test".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||
@@ -5043,8 +5049,9 @@ pub(crate) mod tests {
|
||||
let tool_call =
|
||||
acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
|
||||
|
||||
let permission_options = ToolPermissionContext::new(TerminalTool::NAME, "npm install")
|
||||
.build_permission_options();
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||
@@ -5132,8 +5139,9 @@ pub(crate) mod tests {
|
||||
let tool_call =
|
||||
acp::ToolCall::new(tool_call_id.clone(), "Run `cargo build`").kind(acp::ToolKind::Edit);
|
||||
|
||||
let permission_options = ToolPermissionContext::new(TerminalTool::NAME, "cargo build")
|
||||
.build_permission_options();
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, vec!["cargo build".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||
@@ -5211,8 +5219,9 @@ pub(crate) mod tests {
|
||||
let tool_call =
|
||||
acp::ToolCall::new(tool_call_id.clone(), "Run `npm install`").kind(acp::ToolKind::Edit);
|
||||
|
||||
let permission_options = ToolPermissionContext::new(TerminalTool::NAME, "npm install")
|
||||
.build_permission_options();
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, vec!["npm install".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
// Verify we have the expected options
|
||||
let PermissionOptions::Dropdown(choices) = &permission_options else {
|
||||
@@ -5314,7 +5323,8 @@ pub(crate) mod tests {
|
||||
acp::ToolCall::new(tool_call_id.clone(), "Run `git push`").kind(acp::ToolKind::Edit);
|
||||
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "git push").build_permission_options();
|
||||
ToolPermissionContext::new(TerminalTool::NAME, vec!["git push".to_string()])
|
||||
.build_permission_options();
|
||||
|
||||
let connection =
|
||||
StubAgentConnection::new().with_permission_requests(HashMap::from_iter([(
|
||||
@@ -5373,9 +5383,11 @@ pub(crate) mod tests {
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_option_id_transformation_for_allow() {
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
|
||||
.build_permission_options();
|
||||
let permission_options = ToolPermissionContext::new(
|
||||
TerminalTool::NAME,
|
||||
vec!["cargo build --release".to_string()],
|
||||
)
|
||||
.build_permission_options();
|
||||
|
||||
let PermissionOptions::Dropdown(choices) = permission_options else {
|
||||
panic!("Expected dropdown permission options");
|
||||
@@ -5398,9 +5410,11 @@ pub(crate) mod tests {
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_option_id_transformation_for_deny() {
|
||||
let permission_options =
|
||||
ToolPermissionContext::new(TerminalTool::NAME, "cargo build --release")
|
||||
.build_permission_options();
|
||||
let permission_options = ToolPermissionContext::new(
|
||||
TerminalTool::NAME,
|
||||
vec!["cargo build --release".to_string()],
|
||||
)
|
||||
.build_permission_options();
|
||||
|
||||
let PermissionOptions::Dropdown(choices) = permission_options else {
|
||||
panic!("Expected dropdown permission options");
|
||||
|
||||
@@ -552,7 +552,7 @@ mod tests {
|
||||
default_profile: AgentProfileId::default(),
|
||||
default_view: DefaultAgentView::Thread,
|
||||
profiles: Default::default(),
|
||||
always_allow_tool_actions: false,
|
||||
|
||||
notify_when_agent_waiting: NotifyWhenAgentWaiting::default(),
|
||||
play_sound_when_agent_done: false,
|
||||
single_file_review: false,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -286,3 +286,9 @@ pub(crate) mod m_2026_02_03 {
|
||||
|
||||
pub(crate) use settings::migrate_experimental_sweep_mercury;
|
||||
}
|
||||
|
||||
pub(crate) mod m_2026_02_04 {
|
||||
mod settings;
|
||||
|
||||
pub(crate) use settings::migrate_tool_permission_defaults;
|
||||
}
|
||||
@@ -31,7 +31,7 @@ fn migrate_one(obj: &mut serde_json::Map<String, Value>) -> Result<()> {
|
||||
.or_insert_with(|| Value::Object(Default::default()));
|
||||
|
||||
let Some(edit_predictions_obj) = edit_predictions.as_object_mut() else {
|
||||
anyhow::bail!("Expected edit_predictions to be an object");
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if !edit_predictions_obj.contains_key("provider") {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
use anyhow::{Result, bail};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::migrations::migrate_settings;
|
||||
|
||||
const AGENT_KEY: &str = "agent";
|
||||
const ALWAYS_ALLOW_TOOL_ACTIONS: &str = "always_allow_tool_actions";
|
||||
const DEFAULT_KEY: &str = "default";
|
||||
const DEFAULT_MODE_KEY: &str = "default_mode";
|
||||
const PROFILES_KEY: &str = "profiles";
|
||||
const TOOL_PERMISSIONS_KEY: &str = "tool_permissions";
|
||||
const TOOLS_KEY: &str = "tools";
|
||||
|
||||
pub fn migrate_tool_permission_defaults(value: &mut Value) -> Result<()> {
|
||||
migrate_settings(value, migrate_one)
|
||||
}
|
||||
|
||||
fn migrate_one(obj: &mut serde_json::Map<String, Value>) -> Result<()> {
|
||||
if let Some(agent) = obj.get_mut(AGENT_KEY) {
|
||||
migrate_agent_with_profiles(agent)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_agent_with_profiles(agent: &mut Value) -> Result<()> {
|
||||
migrate_agent_tool_permissions(agent)?;
|
||||
|
||||
if let Some(agent_object) = agent.as_object_mut() {
|
||||
if let Some(profiles) = agent_object.get_mut(PROFILES_KEY) {
|
||||
if let Some(profiles_object) = profiles.as_object_mut() {
|
||||
for (_profile_name, profile) in profiles_object.iter_mut() {
|
||||
migrate_agent_tool_permissions(profile)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_agent_tool_permissions(agent: &mut Value) -> Result<()> {
|
||||
let Some(agent_object) = agent.as_object_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let should_migrate_always_allow = match agent_object.get(ALWAYS_ALLOW_TOOL_ACTIONS) {
|
||||
Some(Value::Bool(true)) => {
|
||||
agent_object.remove(ALWAYS_ALLOW_TOOL_ACTIONS);
|
||||
true
|
||||
}
|
||||
Some(Value::Bool(false)) | Some(Value::Null) | None => {
|
||||
agent_object.remove(ALWAYS_ALLOW_TOOL_ACTIONS);
|
||||
false
|
||||
}
|
||||
Some(_) => {
|
||||
// Non-boolean value — leave it in place so the schema validator
|
||||
// can report it, rather than silently dropping user data.
|
||||
false
|
||||
}
|
||||
};
|
||||
|
||||
if should_migrate_always_allow {
|
||||
if matches!(
|
||||
agent_object.get(TOOL_PERMISSIONS_KEY),
|
||||
None | Some(Value::Null)
|
||||
) {
|
||||
agent_object.insert(
|
||||
TOOL_PERMISSIONS_KEY.to_string(),
|
||||
Value::Object(Default::default()),
|
||||
);
|
||||
}
|
||||
|
||||
let Some(Value::Object(tool_permissions_object)) =
|
||||
agent_object.get_mut(TOOL_PERMISSIONS_KEY)
|
||||
else {
|
||||
bail!(
|
||||
"agent.tool_permissions should be an object or null when migrating \
|
||||
always_allow_tool_actions"
|
||||
);
|
||||
};
|
||||
|
||||
if !tool_permissions_object.contains_key(DEFAULT_KEY)
|
||||
&& !tool_permissions_object.contains_key(DEFAULT_MODE_KEY)
|
||||
{
|
||||
tool_permissions_object
|
||||
.insert(DEFAULT_KEY.to_string(), Value::String("allow".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tool_permissions) = agent_object.get_mut(TOOL_PERMISSIONS_KEY) {
|
||||
migrate_default_mode_to_default(tool_permissions)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_default_mode_to_default(tool_permissions: &mut Value) -> Result<()> {
|
||||
let Some(tool_permissions_object) = tool_permissions.as_object_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if let Some(default_mode) = tool_permissions_object.remove(DEFAULT_MODE_KEY) {
|
||||
if !tool_permissions_object.contains_key(DEFAULT_KEY) {
|
||||
tool_permissions_object.insert(DEFAULT_KEY.to_string(), default_mode);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tools) = tool_permissions_object.get_mut(TOOLS_KEY) {
|
||||
if let Some(tools_object) = tools.as_object_mut() {
|
||||
for (_tool_name, tool_rules) in tools_object.iter_mut() {
|
||||
if let Some(tool_rules_object) = tool_rules.as_object_mut() {
|
||||
if let Some(default_mode) = tool_rules_object.remove(DEFAULT_MODE_KEY) {
|
||||
if !tool_rules_object.contains_key(DEFAULT_KEY) {
|
||||
tool_rules_object.insert(DEFAULT_KEY.to_string(), default_mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -236,6 +236,7 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
|
||||
migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions,
|
||||
),
|
||||
MigrationType::Json(migrations::m_2026_02_03::migrate_experimental_sweep_mercury),
|
||||
MigrationType::Json(migrations::m_2026_02_04::migrate_tool_permission_defaults),
|
||||
];
|
||||
run_migrations(text, migrations)
|
||||
}
|
||||
@@ -2708,6 +2709,31 @@ mod tests {
|
||||
None,
|
||||
);
|
||||
|
||||
// Non-object edit_predictions (e.g. true) should gracefully skip
|
||||
// instead of bail!-ing and aborting the entire migration chain.
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_02::move_edit_prediction_provider_to_edit_predictions,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"features": {
|
||||
"edit_prediction_provider": "copilot"
|
||||
},
|
||||
"edit_predictions": true
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"edit_predictions": true
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Platform key: settings nested inside "macos" should be migrated
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
@@ -3061,4 +3087,737 @@ mod tests {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_migrate_always_allow_tool_actions_to_default() {
|
||||
// No agent settings - no change
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"{ }"#.unindent(),
|
||||
None,
|
||||
);
|
||||
|
||||
// always_allow_tool_actions: true -> tool_permissions.default: "allow"
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// always_allow_tool_actions: false -> just remove it
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": false
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
// The blank line has spaces because the migration preserves the original indentation
|
||||
"{\n \"agent\": {\n \n }\n}\n",
|
||||
),
|
||||
);
|
||||
|
||||
// Preserve existing tool_permissions.tools when migrating
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true,
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"always_deny": [{ "pattern": "rm\\s+-rf" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow",
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"always_deny": [{ "pattern": "rm\\s+-rf" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Don't override existing default (and migrate default_mode to default)
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true,
|
||||
"tool_permissions": {
|
||||
"default_mode": "confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Migrate existing default_mode to default (no always_allow_tool_actions)
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default_mode": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// No migration needed if already using new format with "default"
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
None,
|
||||
);
|
||||
|
||||
// Migrate default_mode to default in tool-specific rules
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default_mode": "confirm",
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default_mode": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "confirm",
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// When tool_permissions is null, replace it so always_allow is preserved
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true,
|
||||
"tool_permissions": null
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Platform-specific agent migration
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"linux": {
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"linux": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Channel-specific agent migration
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
},
|
||||
"nightly": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default_mode": "confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
},
|
||||
"nightly": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Profile-level migration
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"profiles": {
|
||||
"custom": {
|
||||
"always_allow_tool_actions": true,
|
||||
"tool_permissions": {
|
||||
"default_mode": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"profiles": {
|
||||
"custom": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Platform-specific agent with profiles
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"macos": {
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true,
|
||||
"profiles": {
|
||||
"strict": {
|
||||
"tool_permissions": {
|
||||
"default_mode": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"macos": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
},
|
||||
"profiles": {
|
||||
"strict": {
|
||||
"tool_permissions": {
|
||||
"default": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Root-level profile with always_allow_tool_actions
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"profiles": {
|
||||
"work": {
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"profiles": {
|
||||
"work": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Root-level profile with default_mode
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"profiles": {
|
||||
"work": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default_mode": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"profiles": {
|
||||
"work": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Root-level profile + root-level agent both migrated
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
},
|
||||
"profiles": {
|
||||
"strict": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default_mode": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
"strict": {
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Non-boolean always_allow_tool_actions (string "true") is left in place
|
||||
// so the schema validator can report it, rather than silently dropping user data.
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": "true"
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
None,
|
||||
);
|
||||
|
||||
// null always_allow_tool_actions is removed (treated as false)
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": null
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(&"{\n \"agent\": {\n \n }\n}\n"),
|
||||
);
|
||||
|
||||
// Project-local settings (.zed/settings.json) with always_allow_tool_actions
|
||||
// These files have no platform/channel overrides or root-level profiles.
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true,
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default_mode": "confirm",
|
||||
"always_deny": [{ "pattern": "rm\\s+-rf" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow",
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default": "confirm",
|
||||
"always_deny": [{ "pattern": "rm\\s+-rf" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Project-local settings with only default_mode (no always_allow_tool_actions)
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default_mode": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Project-local settings with no agent section at all - no change
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"tab_size": 4,
|
||||
"format_on_save": "on"
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
None,
|
||||
);
|
||||
|
||||
// Existing agent_servers are left untouched
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
},
|
||||
"agent_servers": {
|
||||
"claude": {
|
||||
"default_mode": "plan"
|
||||
},
|
||||
"codex": {
|
||||
"default_mode": "read-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
},
|
||||
"agent_servers": {
|
||||
"claude": {
|
||||
"default_mode": "plan"
|
||||
},
|
||||
"codex": {
|
||||
"default_mode": "read-only"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// Existing agent_servers are left untouched even with partial entries
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
},
|
||||
"agent_servers": {
|
||||
"claude": {
|
||||
"default_mode": "plan"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
},
|
||||
"agent_servers": {
|
||||
"claude": {
|
||||
"default_mode": "plan"
|
||||
}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
),
|
||||
);
|
||||
|
||||
// always_allow_tool_actions: false leaves agent_servers untouched
|
||||
assert_migrate_settings_with_migrations(
|
||||
&[MigrationType::Json(
|
||||
migrations::m_2026_02_04::migrate_tool_permission_defaults,
|
||||
)],
|
||||
&r#"
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": false
|
||||
},
|
||||
"agent_servers": {
|
||||
"claude": {}
|
||||
}
|
||||
}
|
||||
"#
|
||||
.unindent(),
|
||||
Some(
|
||||
"{\n \"agent\": {\n \n },\n \"agent_servers\": {\n \"claude\": {}\n }\n}\n",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,9 +49,7 @@ pub struct AgentSettingsContent {
|
||||
///
|
||||
/// Default: true
|
||||
pub inline_assistant_use_streaming_tools: Option<bool>,
|
||||
/// Model to use for generating git commit messages.
|
||||
///
|
||||
/// Default: true
|
||||
/// Model to use for generating git commit messages. Defaults to default_model when not specified.
|
||||
pub commit_message_model: Option<LanguageModelSelection>,
|
||||
/// Model to use for generating thread summaries. Defaults to default_model when not specified.
|
||||
pub thread_summary_model: Option<LanguageModelSelection>,
|
||||
@@ -67,20 +65,6 @@ pub struct AgentSettingsContent {
|
||||
pub default_view: Option<DefaultAgentView>,
|
||||
/// The available agent profiles.
|
||||
pub profiles: Option<IndexMap<Arc<str>, AgentProfileContent>>,
|
||||
/// Whenever a tool action would normally wait for your confirmation
|
||||
/// that you allow it, always choose to allow it.
|
||||
///
|
||||
/// **Security note**: Even with this enabled, Zed's built-in security rules
|
||||
/// still block some tool actions, such as the terminal tool running `rm -rf /`, `rm -rf ~`,
|
||||
/// `rm -rf $HOME`, `rm -rf .`, or `rm -rf ..`, to prevent certain classes of failures
|
||||
/// from happening.
|
||||
///
|
||||
/// This setting has no effect on external agents that support permission modes, such as Claude Code.
|
||||
///
|
||||
/// Set `agent_servers.claude.default_mode` to `bypassPermissions`, to disable all permission requests when using Claude Code.
|
||||
///
|
||||
/// Default: false
|
||||
pub always_allow_tool_actions: Option<bool>,
|
||||
/// Where to show a popup notification when the agent is waiting for user input.
|
||||
///
|
||||
/// Default: "primary_screen"
|
||||
@@ -131,10 +115,16 @@ pub struct AgentSettingsContent {
|
||||
///
|
||||
/// Default: false
|
||||
pub show_turn_stats: Option<bool>,
|
||||
/// Per-tool permission rules for granular control over which tool actions require confirmation.
|
||||
/// Per-tool permission rules for granular control over which tool actions
|
||||
/// require confirmation.
|
||||
///
|
||||
/// This setting only applies to the native Zed agent. External agent servers (Claude Code, Gemini CLI, etc.)
|
||||
/// have their own permission systems and are not affected by these settings.
|
||||
/// The global `default` applies when no tool-specific rules match.
|
||||
/// For external agent servers (e.g. Claude Code) that define their own
|
||||
/// permission modes, "deny" and "confirm" still take precedence — the
|
||||
/// external agent's permission system is only used when Zed would allow
|
||||
/// the action. Per-tool regex patterns (`always_allow`, `always_deny`,
|
||||
/// `always_confirm`) match against the tool's text input (command, path,
|
||||
/// URL, etc.).
|
||||
pub tool_permissions: Option<ToolPermissionsContent>,
|
||||
}
|
||||
|
||||
@@ -170,13 +160,13 @@ impl AgentSettingsContent {
|
||||
self.favorite_models.retain(|m| m != model);
|
||||
}
|
||||
|
||||
pub fn set_tool_default_mode(&mut self, tool_id: &str, mode: ToolPermissionMode) {
|
||||
pub fn set_tool_default_permission(&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);
|
||||
tool_rules.default = Some(mode);
|
||||
}
|
||||
|
||||
pub fn add_tool_allow_pattern(&mut self, tool_name: &str, pattern: String) {
|
||||
@@ -530,9 +520,16 @@ pub enum CustomAgentServerSettings {
|
||||
#[with_fallible_options]
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
||||
pub struct ToolPermissionsContent {
|
||||
/// Global default permission when no tool-specific rules match.
|
||||
/// Individual tools can override this with their own default.
|
||||
/// Default: confirm
|
||||
#[serde(alias = "default_mode")]
|
||||
pub default: Option<ToolPermissionMode>,
|
||||
|
||||
/// Per-tool permission rules.
|
||||
/// Keys: terminal, edit_file, delete_path, move_path, create_directory,
|
||||
/// save_file, fetch, web_search
|
||||
/// Keys are tool names (e.g. terminal, edit_file, fetch) including MCP
|
||||
/// tools (e.g. mcp:server_name:tool_name). Any tool name is accepted;
|
||||
/// even tools without meaningful text input can have a `default` set.
|
||||
#[serde(default)]
|
||||
pub tools: HashMap<Arc<str>, ToolRulesContent>,
|
||||
}
|
||||
@@ -541,21 +538,34 @@ pub struct ToolPermissionsContent {
|
||||
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, JsonSchema, MergeFrom)]
|
||||
pub struct ToolRulesContent {
|
||||
/// Default mode when no regex rules match.
|
||||
/// Default: confirm
|
||||
pub default_mode: Option<ToolPermissionMode>,
|
||||
/// When unset, inherits from the global `tool_permissions.default`.
|
||||
#[serde(alias = "default_mode")]
|
||||
pub default: Option<ToolPermissionMode>,
|
||||
|
||||
/// Regexes for inputs to auto-approve.
|
||||
/// For terminal: matches command. For file tools: matches path. For fetch: matches URL.
|
||||
/// For `copy_path` and `move_path`, patterns are matched independently against each
|
||||
/// path (source and destination).
|
||||
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
|
||||
/// removed by a higher-priority layer—only new patterns can be added.
|
||||
/// Default: []
|
||||
pub always_allow: Option<ExtendingVec<ToolRegexRule>>,
|
||||
|
||||
/// Regexes for inputs to auto-reject.
|
||||
/// **SECURITY**: These take precedence over ALL other rules, across ALL settings layers.
|
||||
/// For `copy_path` and `move_path`, patterns are matched independently against each
|
||||
/// path (source and destination).
|
||||
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
|
||||
/// removed by a higher-priority layer—only new patterns can be added.
|
||||
/// Default: []
|
||||
pub always_deny: Option<ExtendingVec<ToolRegexRule>>,
|
||||
|
||||
/// Regexes for inputs that must always prompt.
|
||||
/// Takes precedence over always_allow but not always_deny.
|
||||
/// For `copy_path` and `move_path`, patterns are matched independently against each
|
||||
/// path (source and destination).
|
||||
/// Patterns accumulate across settings layers (user, project, profile) and cannot be
|
||||
/// removed by a higher-priority layer—only new patterns can be added.
|
||||
/// Default: []
|
||||
pub always_confirm: Option<ExtendingVec<ToolRegexRule>>,
|
||||
}
|
||||
@@ -586,46 +596,56 @@ pub enum ToolPermissionMode {
|
||||
Confirm,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ToolPermissionMode {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ToolPermissionMode::Allow => write!(f, "Allow"),
|
||||
ToolPermissionMode::Deny => write!(f, "Deny"),
|
||||
ToolPermissionMode::Confirm => write!(f, "Confirm"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_set_tool_default_mode_creates_structure() {
|
||||
fn test_set_tool_default_permission_creates_structure() {
|
||||
let mut settings = AgentSettingsContent::default();
|
||||
assert!(settings.tool_permissions.is_none());
|
||||
|
||||
settings.set_tool_default_mode("terminal", ToolPermissionMode::Allow);
|
||||
settings.set_tool_default_permission("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));
|
||||
assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_tool_default_mode_updates_existing() {
|
||||
fn test_set_tool_default_permission_updates_existing() {
|
||||
let mut settings = AgentSettingsContent::default();
|
||||
|
||||
settings.set_tool_default_mode("terminal", ToolPermissionMode::Confirm);
|
||||
settings.set_tool_default_mode("terminal", ToolPermissionMode::Allow);
|
||||
settings.set_tool_default_permission("terminal", ToolPermissionMode::Confirm);
|
||||
settings.set_tool_default_permission("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));
|
||||
assert_eq!(terminal_rules.default, Some(ToolPermissionMode::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_tool_default_mode_for_mcp_tool() {
|
||||
fn test_set_tool_default_permission_for_mcp_tool() {
|
||||
let mut settings = AgentSettingsContent::default();
|
||||
|
||||
settings.set_tool_default_mode("mcp:github:create_issue", ToolPermissionMode::Allow);
|
||||
settings.set_tool_default_permission("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));
|
||||
assert_eq!(mcp_rules.default, Some(ToolPermissionMode::Allow));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -38,8 +38,8 @@ log.workspace = true
|
||||
menu.workspace = true
|
||||
paths.workspace = true
|
||||
picker.workspace = true
|
||||
platform_title_bar.workspace = true
|
||||
regex.workspace = true
|
||||
platform_title_bar.workspace = true
|
||||
project.workspace = true
|
||||
release_channel.workspace = true
|
||||
schemars.workspace = true
|
||||
|
||||
@@ -6870,31 +6870,9 @@ fn ai_page() -> SettingsPage {
|
||||
]
|
||||
}
|
||||
|
||||
fn agent_configuration_section() -> [SettingsPageItem; 13] {
|
||||
fn agent_configuration_section() -> [SettingsPageItem; 12] {
|
||||
[
|
||||
SettingsPageItem::SectionHeader("Agent Configuration"),
|
||||
SettingsPageItem::SettingItem(SettingItem {
|
||||
title: "Always Allow Tool Actions",
|
||||
description: "When enabled, the agent can run potentially destructive actions without asking for your confirmation. This setting has no effect on external agents.",
|
||||
field: Box::new(SettingField {
|
||||
json_path: Some("agent.always_allow_tool_actions"),
|
||||
pick: |settings_content| {
|
||||
settings_content
|
||||
.agent
|
||||
.as_ref()?
|
||||
.always_allow_tool_actions
|
||||
.as_ref()
|
||||
},
|
||||
write: |settings_content, value| {
|
||||
settings_content
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.always_allow_tool_actions = value;
|
||||
},
|
||||
}),
|
||||
metadata: None,
|
||||
files: USER,
|
||||
}),
|
||||
SettingsPageItem::SubPageLink(SubPageLink {
|
||||
title: "Tool Permissions".into(),
|
||||
r#type: Default::default(),
|
||||
|
||||
@@ -5,7 +5,8 @@ pub(crate) use edit_prediction_provider_setup::render_edit_prediction_setup_page
|
||||
pub(crate) use tool_permissions_setup::render_tool_permissions_setup_page;
|
||||
|
||||
pub use tool_permissions_setup::{
|
||||
render_create_directory_tool_config, render_delete_path_tool_config,
|
||||
render_edit_file_tool_config, render_fetch_tool_config, render_move_path_tool_config,
|
||||
render_copy_path_tool_config, render_create_directory_tool_config,
|
||||
render_delete_path_tool_config, render_edit_file_tool_config, render_fetch_tool_config,
|
||||
render_move_path_tool_config, render_restore_file_from_disk_tool_config,
|
||||
render_save_file_tool_config, render_terminal_tool_config, render_web_search_tool_config,
|
||||
};
|
||||
@@ -9,6 +9,7 @@ use shell_command_parser::extract_commands;
|
||||
use std::sync::Arc;
|
||||
use theme::ThemeSettings;
|
||||
use ui::{Banner, ContextMenu, Divider, PopoverMenu, Severity, Tooltip, prelude::*};
|
||||
use util::ResultExt as _;
|
||||
use util::shell::ShellKind;
|
||||
|
||||
use crate::{SettingsWindow, components::SettingsInputField};
|
||||
@@ -73,6 +74,12 @@ const TOOLS: &[ToolInfo] = &[
|
||||
description: "Web search queries",
|
||||
regex_explanation: "Patterns are matched against the search query.",
|
||||
},
|
||||
ToolInfo {
|
||||
id: "restore_file_from_disk",
|
||||
name: "Restore File from Disk",
|
||||
description: "Discards unsaved changes by reloading from disk",
|
||||
regex_explanation: "Patterns are matched against the file path being restored.",
|
||||
},
|
||||
];
|
||||
|
||||
pub(crate) struct ToolInfo {
|
||||
@@ -156,6 +163,9 @@ pub(crate) fn render_tool_permissions_setup_page(
|
||||
.map(|(i, tool)| render_tool_list_item(settings_window, tool, i, window, cx))
|
||||
.collect();
|
||||
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let global_default = settings.tool_permissions.default;
|
||||
|
||||
let scroll_step = px(40.);
|
||||
|
||||
v_flex()
|
||||
@@ -192,13 +202,16 @@ pub(crate) fn render_tool_permissions_setup_page(
|
||||
),
|
||||
)
|
||||
.child(
|
||||
v_flex().children(tool_items.into_iter().enumerate().flat_map(|(i, item)| {
|
||||
let mut elements: Vec<AnyElement> = vec![item];
|
||||
if i + 1 < TOOLS.len() {
|
||||
elements.push(Divider::horizontal().into_any_element());
|
||||
}
|
||||
elements
|
||||
})),
|
||||
v_flex()
|
||||
.child(render_global_default_mode_section(global_default))
|
||||
.child(Divider::horizontal())
|
||||
.children(tool_items.into_iter().enumerate().flat_map(|(i, item)| {
|
||||
let mut elements: Vec<AnyElement> = vec![item];
|
||||
if i + 1 < TOOLS.len() {
|
||||
elements.push(Divider::horizontal().into_any_element());
|
||||
}
|
||||
elements
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -293,6 +306,7 @@ fn get_tool_render_fn(
|
||||
"save_file" => render_save_file_tool_config,
|
||||
"fetch" => render_fetch_tool_config,
|
||||
"web_search" => render_web_search_tool_config,
|
||||
"restore_file_from_disk" => render_restore_file_from_disk_tool_config,
|
||||
_ => render_terminal_tool_config, // fallback
|
||||
}
|
||||
}
|
||||
@@ -372,7 +386,7 @@ pub(crate) fn render_tool_config_page(
|
||||
.min_w_0()
|
||||
.w_full()
|
||||
.gap_5()
|
||||
.child(render_default_mode_section(tool.id, rules.default_mode, cx))
|
||||
.child(render_default_mode_section(tool.id, rules.default, cx))
|
||||
.child(Divider::horizontal().color(ui::DividerColor::BorderFaded))
|
||||
.child(render_rule_section(
|
||||
tool.id,
|
||||
@@ -440,9 +454,6 @@ fn render_verification_section(
|
||||
) -> AnyElement {
|
||||
let input_id = format!("{}-verification-input", tool_id);
|
||||
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
let always_allow_enabled = settings.always_allow_tool_actions;
|
||||
|
||||
let editor = window.use_keyed_state(input_id, cx, |window, cx| {
|
||||
let mut editor = editor::Editor::single_line(window, cx);
|
||||
editor.set_placeholder_text("Enter a tool input to test your rules…", window, cx);
|
||||
@@ -470,7 +481,7 @@ fn render_verification_section(
|
||||
(Some(decision), matches)
|
||||
};
|
||||
|
||||
let default_mode = get_tool_rules(tool_id, cx).default_mode;
|
||||
let default_mode = get_tool_rules(tool_id, cx).default;
|
||||
let is_hardcoded_denial = matches!(
|
||||
&decision,
|
||||
Some(ToolPermissionDecision::Deny(reason))
|
||||
@@ -504,37 +515,12 @@ fn render_verification_section(
|
||||
None => (None, true),
|
||||
};
|
||||
|
||||
let always_allow_description = "The Always Allow Tool Actions setting is enabled: all tools will be allowed regardless of these rules.";
|
||||
let color = cx.theme().colors();
|
||||
|
||||
v_flex()
|
||||
.mt_3()
|
||||
.min_w_0()
|
||||
.gap_2()
|
||||
.when(always_allow_enabled, |this| {
|
||||
this.child(
|
||||
Banner::new()
|
||||
.severity(Severity::Warning)
|
||||
.wrap_content(false)
|
||||
.child(
|
||||
Label::new(always_allow_description)
|
||||
.size(LabelSize::Small)
|
||||
.mt(px(3.))
|
||||
.mr_8(),
|
||||
)
|
||||
.action_slot(
|
||||
Button::new("configure_setting", "Configure Setting")
|
||||
.label_size(LabelSize::Small)
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.navigate_to_setting(
|
||||
"agent.always_allow_tool_actions",
|
||||
window,
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
v_flex()
|
||||
.p_2p5()
|
||||
@@ -561,11 +547,11 @@ fn render_verification_section(
|
||||
.track_focus(&focus_handle)
|
||||
.child(editor),
|
||||
)
|
||||
.when_some(authoritative_mode, |this, _| {
|
||||
.when_some(authoritative_mode, |this, mode| {
|
||||
this.when(patterns_agree, |this| {
|
||||
if matched_patterns.is_empty() {
|
||||
this.child(
|
||||
Label::new("No regex matches, using the default action (confirm).")
|
||||
Label::new("No regex matches, using the default action.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
@@ -578,24 +564,32 @@ fn render_verification_section(
|
||||
this.child(render_hardcoded_rules(true, cx))
|
||||
} else if let Some(reason) = &denial_reason {
|
||||
this.child(
|
||||
Label::new(reason).size(LabelSize::XSmall),
|
||||
Label::new(format!("Denied: {}", reason))
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Warning),
|
||||
)
|
||||
} else {
|
||||
this.child(
|
||||
Label::new(
|
||||
"Pattern preview differs from engine — showing authoritative result.",
|
||||
)
|
||||
.size(LabelSize::XSmall),
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Warning),
|
||||
)
|
||||
}
|
||||
})
|
||||
.when(is_hardcoded_denial && patterns_agree, |this| {
|
||||
this.child(render_hardcoded_rules(true, cx))
|
||||
})
|
||||
.child(render_verdict_label(mode))
|
||||
.when_some(
|
||||
denial_reason.filter(|_| patterns_agree && !is_hardcoded_denial),
|
||||
|this, reason| {
|
||||
this.child(Label::new(reason).size(LabelSize::XSmall))
|
||||
this.child(
|
||||
Label::new(format!("Reason: {}", reason))
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Error),
|
||||
)
|
||||
},
|
||||
)
|
||||
}),
|
||||
@@ -726,15 +720,12 @@ fn render_matched_patterns(patterns: &[MatchedPattern], cx: &App) -> AnyElement
|
||||
fn evaluate_test_input(tool_id: &str, input: &str, cx: &App) -> ToolPermissionDecision {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
|
||||
// Always pass false for always_allow_tool_actions so we test the actual rules,
|
||||
// not the global override that bypasses all checks.
|
||||
// ShellKind is only used for terminal tool's hardcoded security rules;
|
||||
// for other tools, the check returns None immediately.
|
||||
ToolPermissionDecision::from_input(
|
||||
tool_id,
|
||||
input,
|
||||
&[input.to_string()],
|
||||
&settings.tool_permissions,
|
||||
false,
|
||||
ShellKind::system(),
|
||||
)
|
||||
}
|
||||
@@ -780,6 +771,132 @@ fn mode_display_label(mode: ToolPermissionMode) -> &'static str {
|
||||
}
|
||||
}
|
||||
|
||||
fn verdict_color(mode: ToolPermissionMode) -> Color {
|
||||
match mode {
|
||||
ToolPermissionMode::Allow => Color::Success,
|
||||
ToolPermissionMode::Deny => Color::Error,
|
||||
ToolPermissionMode::Confirm => Color::Warning,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_verdict_label(mode: ToolPermissionMode) -> AnyElement {
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Label::new("Result:")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
Label::new(mode_display_label(mode))
|
||||
.size(LabelSize::Small)
|
||||
.color(verdict_color(mode)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_invalid_patterns_section(
|
||||
tool_id: &'static str,
|
||||
invalid_patterns: &[InvalidPatternView],
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> AnyElement {
|
||||
let section_id = format!("{}-invalid-patterns-section", tool_id);
|
||||
let theme_colors = cx.theme().colors();
|
||||
|
||||
v_flex()
|
||||
.id(section_id)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Icon::new(IconName::Warning)
|
||||
.size(IconSize::Small)
|
||||
.color(Color::Error),
|
||||
)
|
||||
.child(Label::new("Invalid Patterns").color(Color::Error)),
|
||||
)
|
||||
.child(
|
||||
Label::new(
|
||||
"These patterns failed to compile as regular expressions. \
|
||||
The tool will be blocked until they are fixed or removed.",
|
||||
)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.w_full()
|
||||
.gap_1p5()
|
||||
.children(invalid_patterns.iter().map(|invalid| {
|
||||
let rule_type_label = match invalid.rule_type.as_str() {
|
||||
"always_allow" => "Always Allow",
|
||||
"always_deny" => "Always Deny",
|
||||
"always_confirm" => "Always Confirm",
|
||||
other => other,
|
||||
};
|
||||
|
||||
let pattern_for_delete = invalid.pattern.clone();
|
||||
let rule_type = match invalid.rule_type.as_str() {
|
||||
"always_allow" => ToolPermissionMode::Allow,
|
||||
"always_deny" => ToolPermissionMode::Deny,
|
||||
_ => ToolPermissionMode::Confirm,
|
||||
};
|
||||
let tool_id_for_delete = tool_id.to_string();
|
||||
let delete_id =
|
||||
format!("{}-invalid-delete-{}", tool_id, invalid.pattern.clone());
|
||||
|
||||
v_flex()
|
||||
.p_2()
|
||||
.rounded_md()
|
||||
.border_1()
|
||||
.border_color(theme_colors.border_variant)
|
||||
.bg(theme_colors.surface_background.opacity(0.15))
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.justify_between()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1p5()
|
||||
.min_w_0()
|
||||
.child(
|
||||
Label::new(invalid.pattern.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Error)
|
||||
.buffer_font(cx),
|
||||
)
|
||||
.child(
|
||||
Label::new(format!("({})", rule_type_label))
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
IconButton::new(delete_id, IconName::Trash)
|
||||
.icon_size(IconSize::Small)
|
||||
.icon_color(Color::Muted)
|
||||
.tooltip(Tooltip::text("Delete Invalid Pattern"))
|
||||
.on_click(cx.listener(move |_, _, _, cx| {
|
||||
delete_pattern(
|
||||
&tool_id_for_delete,
|
||||
rule_type,
|
||||
&pattern_for_delete,
|
||||
cx,
|
||||
);
|
||||
})),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
Label::new(format!("Error: {}", invalid.error))
|
||||
.size(LabelSize::XSmall)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_rule_section(
|
||||
tool_id: &'static str,
|
||||
title: &'static str,
|
||||
@@ -826,35 +943,6 @@ fn render_rule_section(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_invalid_patterns_section(
|
||||
tool_id: &'static str,
|
||||
invalid_patterns: &[String],
|
||||
_cx: &mut Context<SettingsWindow>,
|
||||
) -> AnyElement {
|
||||
let section_id = format!("{}-invalid-section", tool_id);
|
||||
|
||||
v_flex()
|
||||
.id(section_id)
|
||||
.child(Label::new("Invalid Patterns").color(Color::Error))
|
||||
.child(
|
||||
Label::new("These patterns failed to compile as valid regexes. They will block the tool from running until fixed or removed.")
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
)
|
||||
.child(
|
||||
v_flex()
|
||||
.mt_2()
|
||||
.gap_1()
|
||||
.children(invalid_patterns.iter().map(|description| {
|
||||
Label::new(description.clone())
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Error)
|
||||
.into_any_element()
|
||||
})),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_pattern_empty_state(cx: &mut Context<SettingsWindow>) -> AnyElement {
|
||||
h_flex()
|
||||
.p_2()
|
||||
@@ -883,6 +971,7 @@ fn render_user_pattern_row(
|
||||
let tool_id_for_update = tool_id.to_string();
|
||||
let input_id = format!("{}-{:?}-pattern-{}", tool_id, rule_type, index);
|
||||
let delete_id = format!("{}-{:?}-delete-{}", tool_id, rule_type, index);
|
||||
let settings_window = cx.entity().downgrade();
|
||||
|
||||
SettingsInputField::new()
|
||||
.with_id(input_id)
|
||||
@@ -903,13 +992,33 @@ fn render_user_pattern_row(
|
||||
if let Some(new_pattern) = new_pattern {
|
||||
let new_pattern = new_pattern.trim().to_string();
|
||||
if !new_pattern.is_empty() && new_pattern != pattern_for_update {
|
||||
update_pattern(
|
||||
let updated = update_pattern(
|
||||
&tool_id_for_update,
|
||||
rule_type,
|
||||
&pattern_for_update,
|
||||
new_pattern,
|
||||
new_pattern.clone(),
|
||||
cx,
|
||||
);
|
||||
|
||||
let validation_error = if !updated {
|
||||
Some(
|
||||
"A pattern with that name already exists in this rule list."
|
||||
.to_string(),
|
||||
)
|
||||
} else {
|
||||
match regex::Regex::new(&new_pattern) {
|
||||
Err(err) => Some(format!(
|
||||
"Invalid regex: {err}. Pattern saved but will block this tool until fixed or removed."
|
||||
)),
|
||||
Ok(_) => None,
|
||||
}
|
||||
};
|
||||
settings_window
|
||||
.update(cx, |this, cx| {
|
||||
this.regex_validation_error = validation_error;
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -919,10 +1028,11 @@ fn render_user_pattern_row(
|
||||
fn render_add_pattern_input(
|
||||
tool_id: &'static str,
|
||||
rule_type: ToolPermissionMode,
|
||||
_cx: &mut Context<SettingsWindow>,
|
||||
cx: &mut Context<SettingsWindow>,
|
||||
) -> AnyElement {
|
||||
let tool_id_owned = tool_id.to_string();
|
||||
let input_id = format!("{}-{:?}-new-pattern", tool_id, rule_type);
|
||||
let settings_window = cx.entity().downgrade();
|
||||
|
||||
SettingsInputField::new()
|
||||
.with_id(input_id)
|
||||
@@ -936,16 +1046,72 @@ fn render_add_pattern_input(
|
||||
if let Some(pattern) = pattern {
|
||||
let trimmed = pattern.trim().to_string();
|
||||
if !trimmed.is_empty() {
|
||||
if let Err(err) = regex::Regex::new(&trimmed) {
|
||||
log::warn!("Invalid regex pattern '{}': {}", trimmed, err);
|
||||
}
|
||||
save_pattern(&tool_id_owned, rule_type, trimmed, cx);
|
||||
save_pattern(&tool_id_owned, rule_type, trimmed.clone(), cx);
|
||||
|
||||
let validation_error = match regex::Regex::new(&trimmed) {
|
||||
Err(err) => Some(format!(
|
||||
"Invalid regex: {err}. Pattern saved but will block this tool until fixed or removed."
|
||||
)),
|
||||
Ok(_) => None,
|
||||
};
|
||||
settings_window
|
||||
.update(cx, |this, cx| {
|
||||
this.regex_validation_error = validation_error;
|
||||
cx.notify();
|
||||
})
|
||||
.log_err();
|
||||
}
|
||||
}
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_global_default_mode_section(current_mode: ToolPermissionMode) -> AnyElement {
|
||||
let mode_label = current_mode.to_string();
|
||||
|
||||
h_flex()
|
||||
.mt_4()
|
||||
.justify_between()
|
||||
.child(
|
||||
v_flex()
|
||||
.child(Label::new("Default Permission"))
|
||||
.child(
|
||||
Label::new(
|
||||
"Controls the default behavior for all tool actions. Per-tool rules and patterns can override this.",
|
||||
)
|
||||
.size(LabelSize::Small)
|
||||
.color(Color::Muted),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
PopoverMenu::new("global-default-mode")
|
||||
.trigger(
|
||||
Button::new("global-mode-trigger", mode_label)
|
||||
.tab_index(0_isize)
|
||||
.style(ButtonStyle::Outlined)
|
||||
.size(ButtonSize::Medium)
|
||||
.icon(IconName::ChevronDown)
|
||||
.icon_position(IconPosition::End)
|
||||
.icon_size(IconSize::Small),
|
||||
)
|
||||
.menu(move |window, cx| {
|
||||
Some(ContextMenu::build(window, cx, move |menu, _, _| {
|
||||
menu.entry("Confirm", None, move |_, cx| {
|
||||
set_global_default_permission(ToolPermissionMode::Confirm, cx);
|
||||
})
|
||||
.entry("Allow", None, move |_, cx| {
|
||||
set_global_default_permission(ToolPermissionMode::Allow, cx);
|
||||
})
|
||||
.entry("Deny", None, move |_, cx| {
|
||||
set_global_default_permission(ToolPermissionMode::Deny, cx);
|
||||
})
|
||||
}))
|
||||
})
|
||||
.anchor(gpui::Corner::TopRight),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_default_mode_section(
|
||||
tool_id: &'static str,
|
||||
current_mode: ToolPermissionMode,
|
||||
@@ -1002,12 +1168,18 @@ fn render_default_mode_section(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
struct InvalidPatternView {
|
||||
pattern: String,
|
||||
rule_type: String,
|
||||
error: String,
|
||||
}
|
||||
|
||||
struct ToolRulesView {
|
||||
default_mode: ToolPermissionMode,
|
||||
default: ToolPermissionMode,
|
||||
always_allow: Vec<String>,
|
||||
always_deny: Vec<String>,
|
||||
always_confirm: Vec<String>,
|
||||
invalid_patterns: Vec<String>,
|
||||
invalid_patterns: Vec<InvalidPatternView>,
|
||||
}
|
||||
|
||||
fn get_tool_rules(tool_name: &str, cx: &App) -> ToolRulesView {
|
||||
@@ -1017,7 +1189,7 @@ fn get_tool_rules(tool_name: &str, cx: &App) -> ToolRulesView {
|
||||
|
||||
match tool_rules {
|
||||
Some(rules) => ToolRulesView {
|
||||
default_mode: rules.default_mode,
|
||||
default: rules.default.unwrap_or(settings.tool_permissions.default),
|
||||
always_allow: rules
|
||||
.always_allow
|
||||
.iter()
|
||||
@@ -1036,11 +1208,15 @@ fn get_tool_rules(tool_name: &str, cx: &App) -> ToolRulesView {
|
||||
invalid_patterns: rules
|
||||
.invalid_patterns
|
||||
.iter()
|
||||
.map(|p| format!("{} ({}): {}", p.pattern, p.rule_type, p.error))
|
||||
.map(|p| InvalidPatternView {
|
||||
pattern: p.pattern.clone(),
|
||||
rule_type: p.rule_type.clone(),
|
||||
error: p.error.clone(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
None => ToolRulesView {
|
||||
default_mode: ToolPermissionMode::Confirm,
|
||||
default: settings.tool_permissions.default,
|
||||
always_allow: Vec::new(),
|
||||
always_deny: Vec::new(),
|
||||
always_confirm: Vec::new(),
|
||||
@@ -1086,7 +1262,19 @@ fn update_pattern(
|
||||
old_pattern: &str,
|
||||
new_pattern: String,
|
||||
cx: &mut App,
|
||||
) {
|
||||
) -> bool {
|
||||
let settings = AgentSettings::get_global(cx);
|
||||
if let Some(tool_rules) = settings.tool_permissions.tools.get(tool_name) {
|
||||
let patterns = match rule_type {
|
||||
ToolPermissionMode::Allow => &tool_rules.always_allow,
|
||||
ToolPermissionMode::Deny => &tool_rules.always_deny,
|
||||
ToolPermissionMode::Confirm => &tool_rules.always_confirm,
|
||||
};
|
||||
if patterns.iter().any(|r| r.pattern == new_pattern) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let tool_name = tool_name.to_string();
|
||||
let old_pattern = old_pattern.to_string();
|
||||
|
||||
@@ -1105,12 +1293,17 @@ fn update_pattern(
|
||||
};
|
||||
|
||||
if let Some(list) = rules_list {
|
||||
if let Some(rule) = list.0.iter_mut().find(|r| r.pattern == old_pattern) {
|
||||
rule.pattern = new_pattern;
|
||||
let already_exists = list.0.iter().any(|r| r.pattern == new_pattern);
|
||||
if !already_exists {
|
||||
if let Some(rule) = list.0.iter_mut().find(|r| r.pattern == old_pattern) {
|
||||
rule.pattern = new_pattern;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn delete_pattern(tool_name: &str, rule_type: ToolPermissionMode, pattern: &str, cx: &mut App) {
|
||||
@@ -1138,6 +1331,17 @@ fn delete_pattern(tool_name: &str, rule_type: ToolPermissionMode, pattern: &str,
|
||||
});
|
||||
}
|
||||
|
||||
fn set_global_default_permission(mode: ToolPermissionMode, cx: &mut App) {
|
||||
SettingsStore::global(cx).update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _| {
|
||||
settings
|
||||
.agent
|
||||
.get_or_insert_default()
|
||||
.tool_permissions
|
||||
.get_or_insert_default()
|
||||
.default = Some(mode);
|
||||
});
|
||||
}
|
||||
|
||||
fn set_default_mode(tool_name: &str, mode: ToolPermissionMode, cx: &mut App) {
|
||||
let tool_name = tool_name.to_string();
|
||||
|
||||
@@ -1151,7 +1355,7 @@ fn set_default_mode(tool_name: &str, mode: ToolPermissionMode, cx: &mut App) {
|
||||
.tools
|
||||
.entry(Arc::from(tool_name.as_str()))
|
||||
.or_default();
|
||||
tool_rules.default_mode = Some(mode);
|
||||
tool_rules.default = Some(mode);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1178,6 +1382,10 @@ tool_config_page_fn!(render_create_directory_tool_config, "create_directory");
|
||||
tool_config_page_fn!(render_save_file_tool_config, "save_file");
|
||||
tool_config_page_fn!(render_fetch_tool_config, "fetch");
|
||||
tool_config_page_fn!(render_web_search_tool_config, "web_search");
|
||||
tool_config_page_fn!(
|
||||
render_restore_file_from_disk_tool_config,
|
||||
"restore_file_from_disk"
|
||||
);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -1185,7 +1393,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_all_tools_are_in_tool_info_or_excluded() {
|
||||
// Tools that intentionally don't appear in the permissions UI.
|
||||
// If you add a new tool and this test fails, either:
|
||||
// 1. Add a ToolInfo entry to TOOLS (if the tool has permission checks), or
|
||||
// 2. Add it to this list with a comment explaining why it's excluded.
|
||||
const EXCLUDED_TOOLS: &[&str] = &[
|
||||
// Read-only / low-risk tools that don't call decide_permission_from_settings
|
||||
"diagnostics",
|
||||
"find_path",
|
||||
"grep",
|
||||
@@ -1193,9 +1406,12 @@ mod tests {
|
||||
"now",
|
||||
"open",
|
||||
"read_file",
|
||||
"restore_file_from_disk",
|
||||
"thinking",
|
||||
// streaming_edit_file uses "edit_file" for permission lookups,
|
||||
// so its rules are configured under the edit_file entry.
|
||||
"streaming_edit_file",
|
||||
// Subagent permission checks happen at the level of individual
|
||||
// tool calls within the subagent, not at the spawning level.
|
||||
"subagent",
|
||||
];
|
||||
|
||||
|
||||
@@ -354,7 +354,7 @@ fn run_visual_tests(project_path: PathBuf, update_baseline: bool) -> Result<()>
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.log_err()
|
||||
.flatten();
|
||||
|
||||
if let Some(task) = open_file_task {
|
||||
@@ -1018,7 +1018,7 @@ fn run_breakpoint_hover_visual_tests(
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.log_err()
|
||||
.flatten();
|
||||
|
||||
if let Some(task) = open_file_task {
|
||||
@@ -1588,7 +1588,7 @@ import { AiPaneTabContext } from 'context';
|
||||
None
|
||||
}
|
||||
})
|
||||
.ok()
|
||||
.log_err()
|
||||
.flatten();
|
||||
|
||||
if let Some(task) = open_file_task {
|
||||
@@ -2232,17 +2232,39 @@ fn run_agent_thread_view_test(
|
||||
|
||||
/// Visual test for the Tool Permissions Settings UI page
|
||||
///
|
||||
/// Takes two screenshots:
|
||||
/// 1. The settings page showing the "Configure Tool Rules" item
|
||||
/// 2. The tool permissions sub-page after clicking Configure
|
||||
/// Takes a screenshot showing the tool config page with matched patterns and verdict.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn run_tool_permissions_visual_tests(
|
||||
app_state: Arc<AppState>,
|
||||
cx: &mut VisualTestAppContext,
|
||||
_update_baseline: bool,
|
||||
) -> Result<TestResult> {
|
||||
use agent_settings::{AgentSettings, CompiledRegex, ToolPermissions, ToolRules};
|
||||
use collections::HashMap;
|
||||
use settings::ToolPermissionMode;
|
||||
use zed_actions::OpenSettingsAt;
|
||||
|
||||
// Set up tool permissions with "hi" as both always_deny and always_allow for terminal
|
||||
cx.update(|cx| {
|
||||
let mut tools = HashMap::default();
|
||||
tools.insert(
|
||||
Arc::from("terminal"),
|
||||
ToolRules {
|
||||
default: None,
|
||||
always_allow: vec![CompiledRegex::new("hi", false).unwrap()],
|
||||
always_deny: vec![CompiledRegex::new("hi", false).unwrap()],
|
||||
always_confirm: vec![],
|
||||
invalid_patterns: vec![],
|
||||
},
|
||||
);
|
||||
let mut settings = AgentSettings::get_global(cx).clone();
|
||||
settings.tool_permissions = ToolPermissions {
|
||||
default: ToolPermissionMode::Confirm,
|
||||
tools,
|
||||
};
|
||||
AgentSettings::override_global(settings, cx);
|
||||
});
|
||||
|
||||
// Create a minimal workspace to dispatch the settings action from
|
||||
let window_size = size(px(900.0), px(700.0));
|
||||
let bounds = Bounds {
|
||||
@@ -2310,22 +2332,9 @@ fn run_tool_permissions_visual_tests(
|
||||
let all_windows = cx.update(|cx| cx.windows());
|
||||
let settings_window = all_windows.last().copied().context("No windows found")?;
|
||||
|
||||
// Save screenshot 1: Settings page showing "Configure Tool Rules" item
|
||||
let output_dir = std::env::var("VISUAL_TEST_OUTPUT_DIR")
|
||||
.unwrap_or_else(|_| "target/visual_tests".to_string());
|
||||
std::fs::create_dir_all(&output_dir).ok();
|
||||
|
||||
cx.update_window(settings_window, |_, window, _cx| {
|
||||
window.refresh();
|
||||
})
|
||||
.ok();
|
||||
cx.run_until_parked();
|
||||
|
||||
let output_path = PathBuf::from(&output_dir).join("tool_permissions_settings.png");
|
||||
if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
|
||||
let _: Result<(), _> = screenshot.save(&output_path);
|
||||
println!("Screenshot 1 saved to: {}", output_path.display());
|
||||
}
|
||||
std::fs::create_dir_all(&output_dir).log_err();
|
||||
|
||||
// Navigate to the tool permissions sub-page using the public API
|
||||
let settings_window_handle = settings_window
|
||||
@@ -2346,32 +2355,7 @@ fn run_tool_permissions_visual_tests(
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
// Refresh and redraw
|
||||
cx.update_window(settings_window, |_, window, cx| {
|
||||
window.draw(cx).clear();
|
||||
})
|
||||
.ok();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(settings_window, |_, window, _cx| {
|
||||
window.refresh();
|
||||
})
|
||||
.ok();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Save screenshot 2: The tool permissions sub-page (list of tools)
|
||||
let subpage_output_path = PathBuf::from(&output_dir).join("tool_permissions_subpage.png");
|
||||
|
||||
if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
|
||||
let _: Result<(), _> = screenshot.save(&subpage_output_path);
|
||||
println!(
|
||||
"Screenshot 2 (tool list) saved to: {}",
|
||||
subpage_output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Now navigate into a specific tool (Terminal) to show the tool config page
|
||||
// We need to use push_dynamic_sub_page since the tool pages are nested
|
||||
settings_window_handle
|
||||
.update(cx, |settings_window, window, cx| {
|
||||
settings_window.push_dynamic_sub_page(
|
||||
@@ -2393,40 +2377,71 @@ fn run_tool_permissions_visual_tests(
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
// Refresh and redraw
|
||||
// Refresh and redraw so the "Test Your Rules" input is present
|
||||
cx.update_window(settings_window, |_, window, cx| {
|
||||
window.draw(cx).clear();
|
||||
})
|
||||
.ok();
|
||||
.log_err();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(settings_window, |_, window, _cx| {
|
||||
window.refresh();
|
||||
})
|
||||
.ok();
|
||||
.log_err();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Save screenshot 3: Individual tool config page
|
||||
// Focus the first tab stop in the window (the "Test Your Rules" editor
|
||||
// has tab_index(0) and tab_stop(true)) and type "hi" into it.
|
||||
cx.update_window(settings_window, |_, window, cx| {
|
||||
window.focus_next(cx);
|
||||
})
|
||||
.log_err();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.simulate_input(settings_window, "hi");
|
||||
|
||||
// Let the UI update with the matched patterns
|
||||
for _ in 0..5 {
|
||||
cx.advance_clock(Duration::from_millis(50));
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
// Refresh and redraw
|
||||
cx.update_window(settings_window, |_, window, cx| {
|
||||
window.draw(cx).clear();
|
||||
})
|
||||
.log_err();
|
||||
cx.run_until_parked();
|
||||
|
||||
cx.update_window(settings_window, |_, window, _cx| {
|
||||
window.refresh();
|
||||
})
|
||||
.log_err();
|
||||
cx.run_until_parked();
|
||||
|
||||
// Save screenshot: Tool config page with "hi" typed and matched patterns visible
|
||||
let tool_config_output_path =
|
||||
PathBuf::from(&output_dir).join("tool_permissions_tool_config.png");
|
||||
PathBuf::from(&output_dir).join("tool_permissions_test_rules.png");
|
||||
|
||||
if let Ok(screenshot) = cx.capture_screenshot(settings_window) {
|
||||
let _: Result<(), _> = screenshot.save(&tool_config_output_path);
|
||||
screenshot.save(&tool_config_output_path).log_err();
|
||||
println!(
|
||||
"Screenshot 3 (tool config) saved to: {}",
|
||||
"Screenshot (test rules) saved to: {}",
|
||||
tool_config_output_path.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Clean up - close the settings window
|
||||
let _ = cx.update_window(settings_window, |_, window, _cx| {
|
||||
cx.update_window(settings_window, |_, window, _cx| {
|
||||
window.remove_window();
|
||||
});
|
||||
})
|
||||
.log_err();
|
||||
|
||||
// Close the workspace window
|
||||
let _ = cx.update_window(workspace_window.into(), |_, window, _cx| {
|
||||
cx.update_window(workspace_window.into(), |_, window, _cx| {
|
||||
window.remove_window();
|
||||
});
|
||||
})
|
||||
.log_err();
|
||||
|
||||
cx.run_until_parked();
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
- [Overview](./ai/overview.md)
|
||||
- [Agent Panel](./ai/agent-panel.md)
|
||||
- [Tools](./ai/tools.md)
|
||||
- [Tool Permissions](./ai/tool-permissions.md)
|
||||
- [External Agents](./ai/external-agents.md)
|
||||
- [Inline Assistant](./ai/inline-assistant.md)
|
||||
- [Edit Prediction](./ai/edit-prediction.md)
|
||||
|
||||
@@ -171,11 +171,21 @@ To delete a custom profile, open the Agent Profile modal, select the profile you
|
||||
|
||||
### Tool Approval
|
||||
|
||||
Zed's Agent Panel surfaces the `agent.always_allow_tool_actions` setting that, if turned to `false`, will require you to give permission to any editing attempt as well as tool calls coming from MCP servers.
|
||||
> **Note:** In Zed v0.224.0 and above, tool approval is controlled by `agent.tool_permissions.default`.
|
||||
|
||||
You can change that by setting this key to `true` in either your `settings.json` or via the Agent Panel's settings view.
|
||||
Zed's Agent Panel provides the `agent.tool_permissions.default` setting to control tool approval behavior:
|
||||
|
||||
You can also give more granular permissions through the dropdown that appears in the UI whenever the agent requests authorization to run a tool call.
|
||||
- `"confirm"` (default) — Prompts for approval before running any tool action
|
||||
- `"allow"` — Auto-approves tool actions without prompting
|
||||
- `"deny"` — Blocks all tool actions
|
||||
|
||||
You can change this in either your `settings.json` or via the Agent Panel's settings view.
|
||||
|
||||
Even with `"default": "allow"`, per-tool `always_deny` and `always_confirm` patterns are still respected — so you can auto-approve most actions while blocking or gating specific ones. For the `copy_path` and `move_path` tools, patterns are matched independently against both the source and destination paths. See [Per-tool Permission Rules](./agent-settings.md#per-tool-permission-rules) for details.
|
||||
|
||||
When the agent requests permission for an action, the confirmation dialog includes options to allow or deny once, plus "Always for <tool>" choices that set a tool-level default. When Zed can extract a safe pattern from the input, it also offers pattern-based "Always for ..." choices that add `always_allow`/`always_deny` rules. MCP tools only support tool-level defaults.
|
||||
|
||||
> **Note:** Before Zed v0.224.0, tool approval was controlled by the `agent.always_allow_tool_actions` boolean (default `false`). Set it to `true` to auto-approve tool actions, or leave it `false` to require confirmation for edits and tool calls (including MCP tools).
|
||||
|
||||
### Model Support {#model-support}
|
||||
|
||||
|
||||
+130
-28
@@ -68,7 +68,7 @@ Here's how you can customize your `settings.json` to add this functionality:
|
||||
"inline_alternatives": [
|
||||
{
|
||||
"provider": "zed.dev",
|
||||
"model": "gpt-4-mini"
|
||||
"model": "gpt-5-mini"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -92,7 +92,7 @@ One with Claude Sonnet 4 (the default model), another with GPT-5-mini, and anoth
|
||||
"inline_alternatives": [
|
||||
{
|
||||
"provider": "zed.dev",
|
||||
"model": "gpt-4-mini"
|
||||
"model": "gpt-5-mini"
|
||||
},
|
||||
{
|
||||
"provider": "zed.dev",
|
||||
@@ -108,23 +108,27 @@ One with Claude Sonnet 4 (the default model), another with GPT-5-mini, and anoth
|
||||
Specify a custom temperature for a provider and/or model:
|
||||
|
||||
```json [settings]
|
||||
"model_parameters": [
|
||||
// To set parameters for all requests to OpenAI models:
|
||||
{
|
||||
"provider": "openai",
|
||||
"temperature": 0.5
|
||||
},
|
||||
// To set parameters for all requests in general:
|
||||
{
|
||||
"temperature": 0
|
||||
},
|
||||
// To set parameters for a specific provider and model:
|
||||
{
|
||||
"provider": "zed.dev",
|
||||
"model": "claude-sonnet-4",
|
||||
"temperature": 1.0
|
||||
{
|
||||
"agent": {
|
||||
"model_parameters": [
|
||||
// To set parameters for all requests to OpenAI models:
|
||||
{
|
||||
"provider": "openai",
|
||||
"temperature": 0.5
|
||||
},
|
||||
// To set parameters for all requests in general:
|
||||
{
|
||||
"temperature": 0
|
||||
},
|
||||
// To set parameters for a specific provider and model:
|
||||
{
|
||||
"provider": "zed.dev",
|
||||
"model": "claude-sonnet-4",
|
||||
"temperature": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Agent Panel Settings {#agent-panel-settings}
|
||||
@@ -146,45 +150,143 @@ You can choose between `thread` (the default) and `text_thread`:
|
||||
|
||||
### Font Size
|
||||
|
||||
Use the `agent_font_size` setting to change the font size of rendered agent responses in the panel.
|
||||
Use the `agent_ui_font_size` setting to change the font size of rendered agent responses in the panel.
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent_ui_font_size": 18
|
||||
}
|
||||
```
|
||||
|
||||
> Editors in the Agent Panel—such as the main message textarea—use monospace fonts and are controlled by `agent_buffer_font_size` (which defaults to `buffer_font_size` when unset).
|
||||
|
||||
### Default Tool Permissions
|
||||
|
||||
> **Note:** In Zed v0.224.0 and above, tool approval uses the `agent.tool_permissions` settings described below.
|
||||
|
||||
The `agent.tool_permissions.default` setting controls the baseline tool approval behavior for Zed's native agent:
|
||||
|
||||
- `"confirm"` (default) — Prompts for approval before running any tool action
|
||||
- `"allow"` — Auto-approves tool actions without prompting
|
||||
- `"deny"` — Blocks all tool actions
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"agent_font_size": 18
|
||||
"tool_permissions": {
|
||||
"default": "confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
> Editors in the Agent Panel—whether that is the main message textarea or previous messages—use monospace fonts and therefore, are controlled by the `buffer_font_size` setting, which is defined globally in your `settings.json`.
|
||||
Even with `"default": "allow"`, per-tool `always_deny` and `always_confirm` patterns are still respected, so you can auto-approve most actions while keeping guardrails on dangerous or sensitive ones.
|
||||
|
||||
### Auto-run Commands
|
||||
### Per-tool Permission Rules {#per-tool-permission-rules}
|
||||
|
||||
Control whether to allow the agent to run commands without asking you for permission.
|
||||
The default value is `false`.
|
||||
For granular control over individual tool actions, use the `tools` key inside `tool_permissions` to configure regex-based rules that auto-approve, auto-deny, or always require confirmation for specific inputs.
|
||||
|
||||
Each tool entry supports the following keys:
|
||||
|
||||
- `default` — Fallback when no patterns match: `"confirm"`, `"allow"`, or `"deny"`
|
||||
- `always_allow` — Array of patterns that auto-approve matching actions
|
||||
- `always_deny` — Array of patterns that block matching actions immediately
|
||||
- `always_confirm` — Array of patterns that always prompt for confirmation
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"always_allow_tool_actions": true
|
||||
"tool_permissions": {
|
||||
"default": "allow",
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default": "confirm",
|
||||
"always_allow": [
|
||||
{ "pattern": "^cargo\\s+(build|test|check)" },
|
||||
{ "pattern": "^git\\s+(status|log|diff)" }
|
||||
],
|
||||
"always_deny": [{ "pattern": "rm\\s+-rf\\s+(/|~)" }],
|
||||
"always_confirm": [{ "pattern": "sudo\\s" }]
|
||||
},
|
||||
"edit_file": {
|
||||
"always_deny": [
|
||||
{ "pattern": "\\.env" },
|
||||
{ "pattern": "\\.(pem|key)$" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Pattern Precedence
|
||||
|
||||
When evaluating a tool action, rules are checked in the following order (highest priority first):
|
||||
|
||||
1. **Built-in security rules** — Hardcoded protections (e.g., `rm -rf /`) that cannot be overridden
|
||||
2. **`always_deny`** — Blocks matching actions immediately
|
||||
3. **`always_confirm`** — Requires confirmation for matching actions
|
||||
4. **`always_allow`** — Auto-approves matching actions. For the terminal tool with chained commands (e.g., `echo hello && rm file`), **all** sub-commands must match an `always_allow` pattern
|
||||
5. **Tool-specific `default`** — Per-tool fallback when no patterns match (e.g., `tools.terminal.default`)
|
||||
6. **Global `default`** — Falls back to `tool_permissions.default`
|
||||
|
||||
#### Case Sensitivity
|
||||
|
||||
Patterns are **case-insensitive** by default. To make a pattern case-sensitive, set `case_sensitive` to `true`:
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"pattern": "^Makefile$",
|
||||
"case_sensitive": true
|
||||
}
|
||||
```
|
||||
|
||||
#### `copy_path` and `move_path` Patterns
|
||||
|
||||
For the `copy_path` and `move_path` tools, patterns are matched independently against both the source and destination paths. A `deny` or `confirm` match on **either** path takes effect. For `always_allow`, **both** paths must match for auto-approval.
|
||||
|
||||
#### MCP Tool Permissions
|
||||
|
||||
MCP tools use the key format `mcp:<server_name>:<tool_name>` in the `tools` configuration. For example:
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"mcp:github:create_issue": {
|
||||
"default": "confirm"
|
||||
},
|
||||
"mcp:github:create_pull_request": {
|
||||
"default": "deny"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The `default` key on each MCP tool entry is the primary mechanism for controlling MCP tool permissions. Pattern-based rules (`always_allow`, `always_deny`, `always_confirm`) match against an empty string for MCP tools, so most patterns won't match — use the tool-level `default` instead.
|
||||
|
||||
See the [Tool Permissions](./tool-permissions.md) documentation for more examples and complete details.
|
||||
|
||||
> **Note:** Before Zed v0.224.0, tool approval was controlled by the `agent.always_allow_tool_actions` boolean (default `false`). Set it to `true` to auto-approve tool actions, or leave it `false` to require confirmation for edits and tool calls.
|
||||
|
||||
### Single-file Review
|
||||
|
||||
Control whether to display review actions (accept & reject) in single buffers after the agent is done performing edits.
|
||||
The default value is `false`.
|
||||
The default value is `true`.
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"single_file_review": true
|
||||
"single_file_review": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
When set to false, these controls are only available in the multibuffer review tab.
|
||||
When set to `false`, these controls are only available in the multibuffer review tab.
|
||||
|
||||
### Sound Notification
|
||||
|
||||
|
||||
+16
-2
@@ -130,9 +130,23 @@ As an example, [the Dagger team suggests](https://container-use.com/agent-integr
|
||||
|
||||
### Tool Approval
|
||||
|
||||
Zed's Agent Panel includes the `agent.always_allow_tool_actions` setting that, if set to `false`, will require you to give permission for any editing attempt as well as tool calls coming from MCP servers.
|
||||
> **Note:** In Zed v0.224.0 and above, tool approval for the native Zed agent is controlled by `agent.tool_permissions.default`.
|
||||
|
||||
You can change this by setting this key to `true` in either your `settings.json` or through the Agent Panel's settings view.
|
||||
Zed's Agent Panel provides the `agent.tool_permissions.default` setting to control tool approval behavior for the native Zed agent:
|
||||
|
||||
- `"confirm"` (default) — Prompts for approval before running any tool action, including MCP tool calls
|
||||
- `"allow"` — Auto-approves tool actions without prompting
|
||||
- `"deny"` — Blocks all tool actions
|
||||
|
||||
You can change this in either your `settings.json` or through the Agent Panel settings.
|
||||
|
||||
Even with `"default": "allow"`, per-tool `always_deny` and `always_confirm` patterns are still respected, so you can auto-approve most actions while still blocking or gating sensitive ones.
|
||||
|
||||
For granular control over specific MCP tools, you can configure per-tool permission rules. MCP tools use the key format `mcp:<server>:<tool_name>` — for example, `mcp:github:create_issue`. The `default` key on a per-tool entry is the primary mechanism for MCP tools, since pattern-based rules match against an empty string for MCP tools and most patterns won't match.
|
||||
|
||||
See [Per-tool Permission Rules](./agent-settings.md#per-tool-permission-rules) and [Tool Permissions](./tool-permissions.md) for complete details.
|
||||
|
||||
> **Note:** Before Zed v0.224.0, tool approval was controlled by the `agent.always_allow_tool_actions` boolean (default `false`). Set it to `true` to auto-approve tool actions, or leave it `false` to require confirmation for edits and MCP tool calls.
|
||||
|
||||
### External Agents
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ Zed integrates AI throughout the editor: agentic coding, inline transformations,
|
||||
|
||||
- [Tools](./tools.md): The built-in capabilities agents use: file operations, terminal commands, web search.
|
||||
|
||||
- [Tool Permissions](./tool-permissions.md): Configure granular permission rules for agent tool actions.
|
||||
|
||||
- [Model Context Protocol](./mcp.md): Extend agents with custom tools via MCP servers.
|
||||
|
||||
- [Inline Assistant](./inline-assistant.md): Transform selected code or terminal output with `ctrl-enter`.
|
||||
|
||||
@@ -12,6 +12,8 @@ Zed, including AI features, works without sharing data with us and without authe
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Tool Permissions](./tool-permissions.md): Configure granular rules to control which agent actions are auto-approved, blocked, or require confirmation.
|
||||
|
||||
- [Worktree trust](../worktree-trust.md): How Zed opens files and directories in restricted mode.
|
||||
|
||||
- [Telemetry](../telemetry.md): How Zed collects general telemetry data.
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# Tool Permissions
|
||||
|
||||
Configure which agent actions run automatically and which require your approval.
|
||||
|
||||
> **Note:** In Zed v0.224.0 and above, this page documents the granular `agent.tool_permissions` system.
|
||||
>
|
||||
> **Note:** Before Zed v0.224.0, tool approval was controlled by the `agent.always_allow_tool_actions` boolean (default `false`). Set it to `true` to auto-approve tool actions, or leave it `false` to require confirmation.
|
||||
|
||||
## Quick Start
|
||||
|
||||
You can use Zed's Settings UI to configure tool permissions, or add rules directly to your `settings.json`:
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow",
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default": "confirm",
|
||||
"always_allow": [
|
||||
{ "pattern": "^cargo\\s+(build|test|check)" },
|
||||
{ "pattern": "^npm\\s+(install|test|run)" }
|
||||
],
|
||||
"always_confirm": [{ "pattern": "sudo\\s+/" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This example auto-approves `cargo` and `npm` commands in the terminal tool, while requiring manual confirmation on a case-by-case basis for `sudo` commands. Non-terminal commands follow the global `"default": "allow"` setting, but tool-specific defaults and `always_confirm` rules can still prompt.
|
||||
|
||||
## How It Works
|
||||
|
||||
The `tool_permissions` setting lets you customize tool permissions by specifying regex patterns that:
|
||||
|
||||
- **Auto-approve** actions you trust
|
||||
- **Auto-deny** dangerous actions (blocked even when `tool_permissions.default` is set to `"allow"`)
|
||||
- **Always confirm** sensitive actions regardless of other settings
|
||||
|
||||
## Supported Tools
|
||||
|
||||
| Tool | Input Matched Against |
|
||||
| ------------------------ | ---------------------------- |
|
||||
| `terminal` | The shell command string |
|
||||
| `edit_file` | The file path |
|
||||
| `delete_path` | The path being deleted |
|
||||
| `move_path` | Source and destination paths |
|
||||
| `copy_path` | Source and destination paths |
|
||||
| `create_directory` | The directory path |
|
||||
| `restore_file_from_disk` | The file paths |
|
||||
| `save_file` | The file paths |
|
||||
| `fetch` | The URL |
|
||||
| `web_search` | The search query |
|
||||
|
||||
For MCP tools, use the format `mcp:<server>:<tool_name>`. For example, a tool called `create_issue` on a server called `github` would be `mcp:github:create_issue`.
|
||||
|
||||
## Configuration
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "confirm",
|
||||
"tools": {
|
||||
"<tool_name>": {
|
||||
"default": "confirm",
|
||||
"always_allow": [{ "pattern": "...", "case_sensitive": false }],
|
||||
"always_deny": [{ "pattern": "...", "case_sensitive": false }],
|
||||
"always_confirm": [{ "pattern": "...", "case_sensitive": false }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Options
|
||||
|
||||
| Option | Description |
|
||||
| ---------------- | ------------------------------------------------------------------------------ |
|
||||
| `default` | Fallback when no patterns match: `"confirm"` (default), `"allow"`, or `"deny"` |
|
||||
| `always_allow` | Patterns that auto-approve (unless deny or confirm also matches) |
|
||||
| `always_deny` | Patterns that block immediately—highest priority, cannot be overridden |
|
||||
| `always_confirm` | Patterns that always prompt, even when `tool_permissions.default` is `"allow"` |
|
||||
|
||||
### Pattern Syntax
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"pattern": "your-regex-here",
|
||||
"case_sensitive": false
|
||||
}
|
||||
```
|
||||
|
||||
Patterns use Rust regex syntax. Matching is case-insensitive by default.
|
||||
|
||||
## Rule Precedence
|
||||
|
||||
From highest to lowest priority:
|
||||
|
||||
1. **Built-in security rules** — Hardcoded protections (e.g., `rm -rf /`). Cannot be overridden.
|
||||
2. **`always_deny`** — Blocks matching actions
|
||||
3. **`always_confirm`** — Requires confirmation for matching actions
|
||||
4. **`always_allow`** — Auto-approves matching actions
|
||||
5. **Tool-specific `default`** — Per-tool fallback when no patterns match (e.g., `tools.terminal.default`)
|
||||
6. **Global `default`** — Falls back to `tool_permissions.default` when no tool-specific default is set
|
||||
|
||||
## Examples
|
||||
|
||||
### Terminal: Auto-Approve Build Commands
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"terminal": {
|
||||
"default": "confirm",
|
||||
"always_allow": [
|
||||
{ "pattern": "^cargo\\s+(build|test|check|clippy|fmt)" },
|
||||
{ "pattern": "^npm\\s+(install|test|run|build)" },
|
||||
{ "pattern": "^git\\s+(status|log|diff|branch)" },
|
||||
{ "pattern": "^ls\\b" },
|
||||
{ "pattern": "^cat\\s" }
|
||||
],
|
||||
"always_deny": [
|
||||
{ "pattern": "rm\\s+-rf\\s+(/|~)" },
|
||||
{ "pattern": "sudo\\s+rm" }
|
||||
],
|
||||
"always_confirm": [
|
||||
{ "pattern": "sudo\\s" },
|
||||
{ "pattern": "git\\s+push" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File Editing: Protect Sensitive Files
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"edit_file": {
|
||||
"default": "confirm",
|
||||
"always_allow": [
|
||||
{ "pattern": "\\.(md|txt|json)$" },
|
||||
{ "pattern": "^src/" }
|
||||
],
|
||||
"always_deny": [
|
||||
{ "pattern": "\\.env" },
|
||||
{ "pattern": "secrets?/" },
|
||||
{ "pattern": "\\.(pem|key)$" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Path Deletion: Block Critical Directories
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"delete_path": {
|
||||
"default": "confirm",
|
||||
"always_deny": [
|
||||
{ "pattern": "^/etc" },
|
||||
{ "pattern": "^/usr" },
|
||||
{ "pattern": "\\.git/?$" },
|
||||
{ "pattern": "node_modules/?$" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### URL Fetching: Control External Access
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"fetch": {
|
||||
"default": "confirm",
|
||||
"always_allow": [
|
||||
{ "pattern": "docs\\.rs" },
|
||||
{ "pattern": "github\\.com" }
|
||||
],
|
||||
"always_deny": [{ "pattern": "internal\\.company\\.com" }]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### MCP Tools
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"tools": {
|
||||
"mcp:github:create_issue": {
|
||||
"default": "confirm"
|
||||
},
|
||||
"mcp:github:create_pull_request": {
|
||||
"default": "confirm"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Global Auto-Approve
|
||||
|
||||
To auto-approve all tool actions:
|
||||
|
||||
```json [settings]
|
||||
{
|
||||
"agent": {
|
||||
"tool_permissions": {
|
||||
"default": "allow"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This bypasses confirmation prompts for most actions, but `always_deny`, `always_confirm`, built-in security rules, and paths inside Zed settings directories still prompt or block.
|
||||
|
||||
## Shell Compatibility
|
||||
|
||||
For the `terminal` tool, Zed parses chained commands (e.g., `echo hello && rm file`) to check each sub-command against your patterns.
|
||||
|
||||
All supported shells work with tool permission patterns, including sh, bash, zsh, dash, fish, PowerShell 7+, pwsh, cmd, xonsh, csh, tcsh, Nushell, Elvish, and rc (Plan 9).
|
||||
|
||||
## Writing Patterns
|
||||
|
||||
- Use `\b` for word boundaries: `\brm\b` matches "rm" but not "storm"
|
||||
- Use `^` and `$` to anchor patterns to start/end of input
|
||||
- Escape special characters: `\.` for literal dot, `\\` for backslash
|
||||
- Test carefully—a typo in a deny pattern blocks legitimate actions
|
||||
|
||||
## Built-in Security Rules
|
||||
|
||||
Zed includes a small set of hardcoded security rules that **cannot be overridden** by any setting. These only apply to the **terminal** tool and block recursive deletion of critical directories:
|
||||
|
||||
- `rm -rf /` and `rm -rf /*` — filesystem root
|
||||
- `rm -rf ~` and `rm -rf ~/*` — home directory
|
||||
- `rm -rf $HOME` / `rm -rf ${HOME}` (and `$HOME/*`) — home directory via environment variable
|
||||
- `rm -rf .` and `rm -rf ./*` — current directory
|
||||
- `rm -rf ..` and `rm -rf ../*` — parent directory
|
||||
|
||||
These patterns catch any flag combination (e.g., `-fr`, `-rfv`, `-r -f`, `--recursive --force`) and are case-insensitive. They are checked against both the raw command and each parsed sub-command in chained commands (e.g., `ls && rm -rf /`).
|
||||
|
||||
There are no other built-in rules. The default settings file ({#action zed::OpenDefaultSettings}) includes commented-out examples for protecting `.env` files, secrets directories, and private keys — you can uncomment or adapt these to suit your needs.
|
||||
|
||||
## UI Options
|
||||
|
||||
When the agent requests permission, the dialog includes:
|
||||
|
||||
- **Allow once** / **Deny once** — One-time decision
|
||||
- **Always for <tool>** — Sets a tool-level default to allow or deny
|
||||
- **Always for <pattern>** — Adds an `always_allow` or `always_deny` pattern (when a safe pattern can be extracted)
|
||||
|
||||
Selecting "Always for <tool>" sets `tools.<tool>.default` to allow or deny. When a pattern can be safely extracted, selecting "Always for <pattern>" adds an `always_allow` or `always_deny` rule for that input. MCP tools only support the tool-level option.
|
||||
+16
-8
@@ -2,6 +2,8 @@
|
||||
|
||||
Zed's built-in agent has access to these tools for reading, searching, and editing your codebase.
|
||||
|
||||
You can configure permissions for tool actions, including situations where they are automatically approved, automatically denied, or require your confirmation on a case-by-case basis. See [Tool Permissions](./tool-permissions.md) for the list of permission-gated tools and details.
|
||||
|
||||
## Read & Search Tools
|
||||
|
||||
### `diagnostics`
|
||||
@@ -64,18 +66,24 @@ Deletes a file or directory (including contents recursively) at the specified pa
|
||||
|
||||
Edits files by replacing specific text with new content.
|
||||
|
||||
### `restore_file_from_disk`
|
||||
|
||||
Reloads a file from disk, discarding any unsaved changes in the buffer.
|
||||
|
||||
### `save_file`
|
||||
|
||||
Saves a buffer's current contents to disk, preserving unsaved changes before the agent modifies the file.
|
||||
|
||||
### `move_path`
|
||||
|
||||
Moves or renames a file or directory in the project, performing a rename if only the filename differs.
|
||||
|
||||
### `restore_file_from_disk`
|
||||
|
||||
Discards unsaved changes in open buffers by reloading file contents from disk. Useful for resetting files to their on-disk state before retrying an edit.
|
||||
|
||||
### `save_file`
|
||||
|
||||
Saves files that have unsaved changes. Used when files need to be saved before further edits can be made.
|
||||
|
||||
### `terminal`
|
||||
|
||||
Executes shell commands and returns the combined output, creating a new shell process for each invocation.
|
||||
|
||||
## Other Tools
|
||||
|
||||
### `subagent`
|
||||
|
||||
Spawns a subagent with its own context window to perform a delegated task. Useful for running parallel investigations, completing self-contained tasks, or performing research where only the outcome matters. Each subagent has access to the same tools as the parent agent.
|
||||
Reference in new issue
Block a user