Adds buttons for confirming (and optionally persisting) granular permissions on tool calls: <img width="688" height="302" alt="Screenshot 2026-01-14 at 1 58 40 PM" src="https://github.com/user-attachments/assets/3228cc39-efd5-4a73-989f-ddb28969847f" /> <img width="690" height="282" alt="Screenshot 2026-01-14 at 1 58 31 PM" src="https://github.com/user-attachments/assets/8367a888-5728-4877-a502-1ff20ac929ec" /> ## Features ### Tool Permission Settings - **Per-tool rules** in `agent.tool_permissions.tools.<tool_name>`: - `default_mode`: `"allow"`, `"deny"`, or `"confirm"` (default) - `always_allow`: Array of regex patterns to auto-approve - `always_deny`: Array of regex patterns to block - `always_confirm`: Array of regex patterns requiring confirmation - **Supported tools**: `terminal`, `edit_file`, `delete_path`, `move_path`, `create_directory`, `save_file`, `copy_path`, `fetch`, `web_search` - **MCP tool support**: Third-party tools from context servers with `mcp:<server>:<tool>` naming ### Smart Permission Buttons When a tool requires confirmation, the dialog shows contextual buttons: - **"Always allow \`<tool>\`"** - Sets `default_mode = "allow"` for the tool - **"Always allow \`<pattern>\`"** - Adds a pattern to `always_allow`: - Terminal: Command name (e.g., `cargo`, `npm`, `git`) - File tools: Parent directory path - URL tools: Domain name - **"Allow"** / **"Deny"** - One-time decision ### Pattern Extraction Automatically extracts meaningful patterns from tool inputs: - Terminal commands → `^cargo\s`, `^npm\s`, etc. - File paths → `^/Users/alice/project/src/` - URLs → `^https?://github\.com` ### Invalid Pattern Handling - Malformed regex patterns are detected at settings load time - Tools with invalid patterns show an error and block execution - Clear error messages identify which pattern failed ## Changes ### New Files - `crates/agent/src/tool_permissions.rs` - Permission evaluation logic - `crates/agent/src/pattern_extraction.rs` - Pattern extraction functions ### Modified Files - `crates/agent_settings/src/agent_settings.rs` - `ToolPermissions`, `ToolRules` structs - `crates/settings/src/settings_content/agent.rs` - Settings schema and serialization - `crates/settings/src/settings_file.rs` - Helper methods for modifying settings - `crates/agent/src/thread.rs` - `authorize_with_context()`, `authorize_third_party_tool()` methods - `crates/agent/src/tools/*.rs` - Updated all granular tools to use permission system - `crates/agent/src/tools/context_server_registry.rs` - MCP tool authorization - `crates/agent_ui/src/acp/thread_view.rs` - Permission button UI, removed Keep/Reject buttons ## Example Configuration ```json { "agent": { "tool_permissions": { "tools": { "terminal": { "default_mode": "confirm", "always_allow": ["^cargo\\s", "^npm\\s", "^git\\s"], "always_deny": ["^rm\\s+-rf", "^sudo\\s"] }, "edit_file": { "default_mode": "allow", "always_deny": ["^\\.env", "^/etc/"] }, "mcp:filesystem:write_file": { "default_mode": "confirm" } } } } } ``` Release Notes: - You can now set per-tool permissions when confirming/denying tool use --------- Co-authored-by: Amp <amp@ampcode.com> Co-authored-by: Zed Zippy <234243425+zed-zippy[bot]@users.noreply.github.com> Co-authored-by: Michael Benfield <mbenfield@zed.dev>
403 lines
13 KiB
Rust
403 lines
13 KiB
Rust
use agent_client_protocol as acp;
|
|
use agent_settings::AgentSettings;
|
|
use anyhow::Result;
|
|
use collections::FxHashSet;
|
|
use futures::FutureExt as _;
|
|
use gpui::{App, Entity, SharedString, Task};
|
|
use language::Buffer;
|
|
use project::Project;
|
|
use schemars::JsonSchema;
|
|
use serde::{Deserialize, Serialize};
|
|
use settings::Settings;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
use util::markdown::MarkdownInlineCode;
|
|
|
|
use crate::{
|
|
AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
|
|
};
|
|
|
|
/// 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.
|
|
/// Only use this tool after asking the user for permission to save their unsaved changes.
|
|
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
|
|
pub struct SaveFileToolInput {
|
|
/// The paths of the files to save.
|
|
pub paths: Vec<PathBuf>,
|
|
}
|
|
|
|
pub struct SaveFileTool {
|
|
project: Entity<Project>,
|
|
}
|
|
|
|
impl SaveFileTool {
|
|
pub fn new(project: Entity<Project>) -> Self {
|
|
Self { project }
|
|
}
|
|
}
|
|
|
|
impl AgentTool for SaveFileTool {
|
|
type Input = SaveFileToolInput;
|
|
type Output = String;
|
|
|
|
fn name() -> &'static str {
|
|
"save_file"
|
|
}
|
|
|
|
fn kind() -> acp::ToolKind {
|
|
acp::ToolKind::Other
|
|
}
|
|
|
|
fn initial_title(
|
|
&self,
|
|
input: Result<Self::Input, serde_json::Value>,
|
|
_cx: &mut App,
|
|
) -> SharedString {
|
|
match input {
|
|
Ok(input) if input.paths.len() == 1 => "Save file".into(),
|
|
Ok(input) => format!("Save {} files", input.paths.len()).into(),
|
|
Err(_) => "Save files".into(),
|
|
}
|
|
}
|
|
|
|
fn run(
|
|
self: Arc<Self>,
|
|
input: Self::Input,
|
|
event_stream: ToolCallEventStream,
|
|
cx: &mut App,
|
|
) -> Task<Result<String>> {
|
|
let settings = AgentSettings::get_global(cx);
|
|
let mut needs_confirmation = false;
|
|
|
|
for path in &input.paths {
|
|
let path_str = path.to_string_lossy();
|
|
let decision = decide_permission_from_settings(Self::name(), &path_str, settings);
|
|
match decision {
|
|
ToolPermissionDecision::Allow => {}
|
|
ToolPermissionDecision::Deny(reason) => {
|
|
return Task::ready(Err(anyhow::anyhow!("{}", reason)));
|
|
}
|
|
ToolPermissionDecision::Confirm => {
|
|
needs_confirmation = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
let authorize = if needs_confirmation {
|
|
let title = if input.paths.len() == 1 {
|
|
format!(
|
|
"Save {}",
|
|
MarkdownInlineCode(&input.paths[0].to_string_lossy())
|
|
)
|
|
} else {
|
|
let paths: Vec<_> = input
|
|
.paths
|
|
.iter()
|
|
.take(3)
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.collect();
|
|
if input.paths.len() > 3 {
|
|
format!(
|
|
"Save {}, and {} more",
|
|
paths.join(", "),
|
|
input.paths.len() - 3
|
|
)
|
|
} else {
|
|
format!("Save {}", paths.join(", "))
|
|
}
|
|
};
|
|
let first_path = input
|
|
.paths
|
|
.first()
|
|
.map(|p| p.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
let context = crate::ToolPermissionContext {
|
|
tool_name: "save_file".to_string(),
|
|
input_value: first_path,
|
|
};
|
|
Some(event_stream.authorize(title, context, cx))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
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_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
|
|
|
|
let mut saved_paths: Vec<PathBuf> = Vec::new();
|
|
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 {
|
|
let Some(project_path) =
|
|
project.read_with(cx, |project, cx| project.find_project_path(&path, cx))
|
|
else {
|
|
not_found_paths.push(path);
|
|
continue;
|
|
};
|
|
|
|
let open_buffer_task =
|
|
project.update(cx, |project, cx| project.open_buffer(project_path, cx));
|
|
|
|
let buffer = futures::select! {
|
|
result = open_buffer_task.fuse() => {
|
|
match result {
|
|
Ok(buffer) => buffer,
|
|
Err(error) => {
|
|
open_errors.push((path, error.to_string()));
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
_ = event_stream.cancelled_by_user().fuse() => {
|
|
anyhow::bail!("Save cancelled by user");
|
|
}
|
|
};
|
|
|
|
let is_dirty = buffer.read_with(cx, |buffer, _| buffer.is_dirty());
|
|
|
|
if is_dirty {
|
|
buffers_to_save.insert(buffer);
|
|
saved_paths.push(path);
|
|
} else {
|
|
clean_paths.push(path);
|
|
}
|
|
}
|
|
|
|
// Save each buffer individually since there's no batch save API.
|
|
for buffer in buffers_to_save {
|
|
let path_for_buffer = buffer
|
|
.read_with(cx, |buffer, _| {
|
|
buffer
|
|
.file()
|
|
.map(|file| file.path().to_rel_path_buf())
|
|
.map(|path| path.as_rel_path().as_unix_str().to_owned())
|
|
})
|
|
.unwrap_or_else(|| "<unknown>".to_string());
|
|
|
|
let save_task = project.update(cx, |project, cx| project.save_buffer(buffer, cx));
|
|
|
|
let save_result = futures::select! {
|
|
result = save_task.fuse() => result,
|
|
_ = event_stream.cancelled_by_user().fuse() => {
|
|
anyhow::bail!("Save cancelled by user");
|
|
}
|
|
};
|
|
if let Err(error) = save_result {
|
|
save_errors.push((path_for_buffer, error.to_string()));
|
|
}
|
|
}
|
|
|
|
let mut lines: Vec<String> = Vec::new();
|
|
|
|
if !saved_paths.is_empty() {
|
|
lines.push(format!("Saved {} file(s).", saved_paths.len()));
|
|
}
|
|
if !clean_paths.is_empty() {
|
|
lines.push(format!("{} clean.", clean_paths.len()));
|
|
}
|
|
|
|
if !not_found_paths.is_empty() {
|
|
lines.push(format!("Not found ({}):", not_found_paths.len()));
|
|
for path in ¬_found_paths {
|
|
lines.push(format!("- {}", path.display()));
|
|
}
|
|
}
|
|
if !open_errors.is_empty() {
|
|
lines.push(format!("Open failed ({}):", open_errors.len()));
|
|
for (path, error) in &open_errors {
|
|
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 {
|
|
lines.push(format!("- {}: {}", path, error));
|
|
}
|
|
}
|
|
|
|
if lines.is_empty() {
|
|
Ok("No paths provided.".to_string())
|
|
} else {
|
|
Ok(lines.join("\n"))
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use fs::Fs;
|
|
use gpui::TestAppContext;
|
|
use project::FakeFs;
|
|
use serde_json::json;
|
|
use settings::SettingsStore;
|
|
use util::path;
|
|
|
|
fn init_test(cx: &mut TestAppContext) {
|
|
cx.update(|cx| {
|
|
let settings_store = SettingsStore::test(cx);
|
|
cx.set_global(settings_store);
|
|
});
|
|
cx.update(|cx| {
|
|
let mut settings = AgentSettings::get_global(cx).clone();
|
|
settings.always_allow_tool_actions = true;
|
|
AgentSettings::override_global(settings, cx);
|
|
});
|
|
}
|
|
|
|
#[gpui::test]
|
|
async fn test_save_file_output_and_effects(cx: &mut TestAppContext) {
|
|
init_test(cx);
|
|
|
|
let fs = FakeFs::new(cx.executor());
|
|
fs.insert_tree(
|
|
"/root",
|
|
json!({
|
|
"dirty.txt": "on disk: dirty\n",
|
|
"clean.txt": "on disk: clean\n",
|
|
}),
|
|
)
|
|
.await;
|
|
|
|
let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await;
|
|
let tool = Arc::new(SaveFileTool::new(project.clone()));
|
|
|
|
// Make dirty.txt dirty in-memory.
|
|
let dirty_project_path = project.read_with(cx, |project, cx| {
|
|
project
|
|
.find_project_path("root/dirty.txt", cx)
|
|
.expect("dirty.txt should exist in project")
|
|
});
|
|
|
|
let dirty_buffer = project
|
|
.update(cx, |project, cx| {
|
|
project.open_buffer(dirty_project_path, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
dirty_buffer.update(cx, |buffer, cx| {
|
|
buffer.edit([(0..buffer.len(), "in memory: dirty\n")], None, cx);
|
|
});
|
|
assert!(
|
|
dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
|
|
"dirty.txt buffer should be dirty before save"
|
|
);
|
|
|
|
// Ensure clean.txt is opened but remains clean.
|
|
let clean_project_path = project.read_with(cx, |project, cx| {
|
|
project
|
|
.find_project_path("root/clean.txt", cx)
|
|
.expect("clean.txt should exist in project")
|
|
});
|
|
|
|
let clean_buffer = project
|
|
.update(cx, |project, cx| {
|
|
project.open_buffer(clean_project_path, cx)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
!clean_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
|
|
"clean.txt buffer should start clean"
|
|
);
|
|
|
|
let output = cx
|
|
.update(|cx| {
|
|
tool.clone().run(
|
|
SaveFileToolInput {
|
|
paths: vec![
|
|
PathBuf::from("root/dirty.txt"),
|
|
PathBuf::from("root/clean.txt"),
|
|
],
|
|
},
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
|
|
// Output should mention saved + clean.
|
|
assert!(
|
|
output.contains("Saved 1 file(s)."),
|
|
"expected saved count line, got:\n{output}"
|
|
);
|
|
assert!(
|
|
output.contains("1 clean."),
|
|
"expected clean count line, got:\n{output}"
|
|
);
|
|
|
|
// Effect: dirty buffer should now be clean and disk should have new content.
|
|
assert!(
|
|
!dirty_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
|
|
"dirty.txt buffer should not be dirty after save"
|
|
);
|
|
|
|
let disk_dirty = fs.load(path!("/root/dirty.txt").as_ref()).await.unwrap();
|
|
assert_eq!(
|
|
disk_dirty, "in memory: dirty\n",
|
|
"dirty.txt disk content should be updated"
|
|
);
|
|
|
|
// Sanity: clean buffer should remain clean and disk unchanged.
|
|
let disk_clean = fs.load(path!("/root/clean.txt").as_ref()).await.unwrap();
|
|
assert_eq!(disk_clean, "on disk: clean\n");
|
|
|
|
// Test empty paths case.
|
|
let output = cx
|
|
.update(|cx| {
|
|
tool.clone().run(
|
|
SaveFileToolInput { paths: vec![] },
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(output, "No paths provided.");
|
|
|
|
// Test not-found path case.
|
|
let output = cx
|
|
.update(|cx| {
|
|
tool.clone().run(
|
|
SaveFileToolInput {
|
|
paths: vec![PathBuf::from("nonexistent/path.txt")],
|
|
},
|
|
ToolCallEventStream::test().0,
|
|
cx,
|
|
)
|
|
})
|
|
.await
|
|
.unwrap();
|
|
assert!(
|
|
output.contains("Not found (1):"),
|
|
"expected not-found header line, got:\n{output}"
|
|
);
|
|
assert!(
|
|
output.contains("- nonexistent/path.txt"),
|
|
"expected not-found path bullet, got:\n{output}"
|
|
);
|
|
}
|
|
}
|