zed/crates/agent/src/tools/delete_path_tool.rs
MartinYe1234 d2cbf930f7
Allow path tools to operate on global agent skills (#57760)
Extends the same `~/.agents/skills` special case that
`create_directory`, `edit_file`, and `write_file` already use to the
path tools, so the agent can copy or move skills into and out of the
global skills folder and delete individual skills or skill resources
beneath it.

- `copy_path` now allows source and/or destination to be a descendant of
`~/.agents/skills`, going through `fs::copy_recursive` directly when one
side is outside the project.
- `move_path` now allows source and/or destination to be a descendant of
`~/.agents/skills`, going through `fs.rename` directly when one side is
outside the project. Moving the `~/.agents/skills` root itself is
rejected.
- `delete_path` now allows deleting any file or directory beneath
`~/.agents/skills`, going through `fs.remove_dir` / `fs.remove_file`
directly. Deleting the `~/.agents/skills` root itself is rejected.
- All three tools still always prompt for approval on agent-skill paths,
even when default tool permissions are set to allow.
- Added shared helpers in `tool_permissions.rs` for resolving global
skill descendants and rejecting operations on the skills root where
needed.
- Added tests covering copying and moving skills in both directions,
deleting a global skill directory/file, and rejecting deletion of the
skills root.

Release Notes:

- Agent can now copy or move skills into and out of `~/.agents/skills`
and delete individual skills, with an explicit confirmation prompt for
each operation
2026-05-27 14:02:18 +00:00

702 lines
25 KiB
Rust

use super::tool_permissions::{
authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape,
resolve_global_skill_descendant_path, resolves_to_global_skills_dir, sensitive_settings_kind,
};
use crate::{
AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
authorize_with_sensitive_settings, decide_permission_for_path,
};
use action_log::ActionLog;
use agent_client_protocol::schema as acp;
use agent_settings::AgentSettings;
use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc};
use gpui::{App, AppContext, Entity, SharedString, Task};
use project::{Project, ProjectPath};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use settings::Settings;
use std::path::Path;
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion.
///
/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
pub struct DeletePathToolInput {
/// The path of the file or directory to delete.
///
/// <example>
/// If the project has the following files:
///
/// - directory1/a/something.txt
/// - directory2/a/things.txt
/// - directory3/a/other.txt
///
/// You can delete the first file by providing a path of "directory1/a/something.txt"
/// </example>
pub path: String,
}
pub struct DeletePathTool {
project: Entity<Project>,
action_log: Entity<ActionLog>,
}
impl DeletePathTool {
pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
Self {
project,
action_log,
}
}
}
impl AgentTool for DeletePathTool {
type Input = DeletePathToolInput;
type Output = String;
const NAME: &'static str = "delete_path";
fn kind() -> acp::ToolKind {
acp::ToolKind::Delete
}
fn initial_title(
&self,
input: Result<Self::Input, serde_json::Value>,
_cx: &mut App,
) -> SharedString {
if let Ok(input) = input {
format!("Delete “`{}`”", input.path).into()
} else {
"Delete path".into()
}
}
fn run(
self: Arc<Self>,
input: ToolInput<Self::Input>,
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output, Self::Output>> {
let project = self.project.clone();
let action_log = self.action_log.clone();
cx.spawn(async move |cx| {
let input = input.recv().await.map_err(|e| e.to_string())?;
let path = input.path;
let decision = cx.update(|cx| {
decide_permission_for_path(Self::NAME, &path, AgentSettings::get_global(cx))
});
if let ToolPermissionDecision::Deny(reason) = decision {
return Err(reason);
}
let fs = project.read_with(cx, |project, _cx| project.fs().clone());
let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
if resolves_to_global_skills_dir(Path::new(&path), fs.as_ref()).await {
return Err(
"Cannot delete the global agent skills directory itself. Delete a skill directory or file beneath it instead."
.to_string(),
);
}
let global_skill_path =
resolve_global_skill_descendant_path(Path::new(&path), fs.as_ref()).await;
let symlink_escape_target = project.read_with(cx, |project, cx| {
detect_symlink_escape(project, &path, &canonical_roots, cx)
.map(|(_, target)| target)
});
let settings_kind =
sensitive_settings_kind(Path::new(&path), &canonical_roots, fs.as_ref()).await;
let decision =
if matches!(decision, ToolPermissionDecision::Allow) && settings_kind.is_some() {
ToolPermissionDecision::Confirm
} else {
decision
};
let authorize = if let Some(canonical_target) = symlink_escape_target {
// Symlink escape authorization replaces (rather than supplements)
// the normal tool-permission prompt. The symlink prompt already
// requires explicit user approval with the canonical target shown,
// which is strictly more security-relevant than a generic confirm.
Some(cx.update(|cx| {
authorize_symlink_access(
Self::NAME,
&path,
&canonical_target,
&event_stream,
cx,
)
}))
} else {
match decision {
ToolPermissionDecision::Allow => None,
ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
let context =
crate::ToolPermissionContext::new(Self::NAME, vec![path.clone()]);
let title = format!("Delete {}", MarkdownInlineCode(&path));
authorize_with_sensitive_settings(
settings_kind,
context,
&title,
&event_stream,
cx,
)
})),
ToolPermissionDecision::Deny(_) => None,
}
};
if let Some(authorize) = authorize {
authorize.await.map_err(|e| e.to_string())?;
}
if let Some(global_skill_path) = global_skill_path {
let metadata = fs
.metadata(&global_skill_path)
.await
.map_err(|e| format!("Deleting {path}: {e}"))?
.ok_or_else(|| format!("Deleting {path}: path not found"))?;
futures::select! {
result = async {
if metadata.is_dir {
fs.remove_dir(
&global_skill_path,
fs::RemoveOptions {
recursive: true,
..fs::RemoveOptions::default()
},
)
.await
} else {
fs.remove_file(&global_skill_path, fs::RemoveOptions::default()).await
}
}.fuse() => {
result.map_err(|e| format!("Deleting {path}: {e}"))?;
}
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Delete cancelled by user".to_string());
}
}
return Ok(format!("Deleted {path}"));
}
let (project_path, worktree_snapshot) = project.read_with(cx, |project, cx| {
let project_path = project.find_project_path(&path, cx).ok_or_else(|| {
format!("Couldn't delete {path} because that path isn't in this project.")
})?;
let worktree = project
.worktree_for_id(project_path.worktree_id, cx)
.ok_or_else(|| {
format!("Couldn't delete {path} because that path isn't in this project.")
})?;
let worktree_snapshot = worktree.read(cx).snapshot();
Result::<_, String>::Ok((project_path, worktree_snapshot))
})?;
let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
cx.background_spawn({
let project_path = project_path.clone();
async move {
for entry in
worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
{
if !entry.path.starts_with(&project_path.path) {
break;
}
paths_tx
.send(ProjectPath {
worktree_id: project_path.worktree_id,
path: entry.path.clone(),
})
.await?;
}
anyhow::Ok(())
}
})
.detach();
loop {
let path_result = futures::select! {
path = paths_rx.next().fuse() => path,
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Delete cancelled by user".to_string());
}
};
let Some(path) = path_result else {
break;
};
if let Ok(buffer) = project
.update(cx, |project, cx| project.open_buffer(path, cx))
.await
{
action_log.update(cx, |action_log, cx| {
action_log.will_delete_buffer(buffer.clone(), cx)
});
}
}
let deletion_task = project
.update(cx, |project, cx| {
project.delete_file(project_path, false, cx)
})
.ok_or_else(|| {
format!("Couldn't delete {path} because that path isn't in this project.")
})?;
futures::select! {
result = deletion_task.fuse() => {
result.map_err(|e| format!("Deleting {path}: {e}"))?;
}
_ = event_stream.cancelled_by_user().fuse() => {
return Err("Delete cancelled by user".to_string());
}
}
Ok(format!("Deleted {path}"))
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use fs::Fs as _;
use gpui::TestAppContext;
use project::{FakeFs, Project};
use serde_json::json;
use settings::SettingsStore;
use std::path::PathBuf;
use util::path;
use crate::ToolCallEventStream;
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.tool_permissions.default = settings::ToolPermissionMode::Allow;
AgentSettings::override_global(settings, cx);
});
}
#[gpui::test]
async fn test_delete_path_global_skill_directory(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root/project"), json!({})).await;
let skills_dir = agent_skills::global_skills_dir();
let skill_dir = skills_dir.join("my-skill");
fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" }))
.await;
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(DeletePathTool::new(project, action_log));
let input_path = PathBuf::from("~")
.join(".agents")
.join("skills")
.join("my-skill")
.to_string_lossy()
.into_owned();
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(DeletePathToolInput { path: input_path }),
event_stream,
cx,
)
});
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("agent skills"),
"Authorization title should mention agent skills, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.expect("authorization response should send");
let result = task.await;
assert!(result.is_ok(), "should delete after approval: {result:?}");
assert!(fs.is_dir(&skills_dir).await);
assert!(!fs.is_dir(&skill_dir).await);
}
#[gpui::test]
async fn test_delete_path_global_skill_file(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root/project"), json!({})).await;
let skill_file = agent_skills::global_skills_dir()
.join("my-skill")
.join("references")
.join("notes.md");
fs.create_dir(skill_file.parent().unwrap()).await.unwrap();
fs.insert_file(&skill_file, b"notes".to_vec()).await;
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(DeletePathTool::new(project, action_log));
let input_path = PathBuf::from("~")
.join(".agents")
.join("skills")
.join("my-skill")
.join("references")
.join("notes.md")
.to_string_lossy()
.into_owned();
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(DeletePathToolInput { path: input_path }),
event_stream,
cx,
)
});
let auth = event_rx.expect_authorization().await;
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.expect("authorization response should send");
let result = task.await;
assert!(result.is_ok(), "should delete after approval: {result:?}");
assert!(!fs.is_file(&skill_file).await);
}
#[gpui::test]
async fn test_delete_path_rejects_global_skills_root(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(path!("/root/project"), json!({})).await;
let skills_dir = agent_skills::global_skills_dir();
fs.create_dir(&skills_dir).await.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(DeletePathTool::new(project, action_log));
let input_path = PathBuf::from("~")
.join(".agents")
.join("skills")
.to_string_lossy()
.into_owned();
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
tool.run(
ToolInput::resolved(DeletePathToolInput { path: input_path }),
event_stream,
cx,
)
})
.await;
assert!(result.is_err(), "should reject deleting skills root");
assert!(fs.is_dir(&skills_dir).await);
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Deleting the skills root should fail before requesting authorization",
);
}
#[gpui::test]
async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": { "main.rs": "fn main() {}" }
},
"external": {
"data": { "file.txt": "content" }
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link_to_external").as_ref(),
PathBuf::from("../external"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(DeletePathTool::new(project, action_log));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
}),
event_stream,
cx,
)
});
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project") || title.contains("symlink"),
"Authorization title should mention symlink escape, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
let result = task.await;
// FakeFs cannot delete symlink entries (they are neither Dir nor File
// internally), so the deletion itself may fail. The important thing is
// that the authorization was requested and accepted — any error must
// come from the fs layer, not from a permission denial.
if let Err(err) = &result {
let msg = format!("{err:#}");
assert!(
!msg.contains("denied") && !msg.contains("authorization"),
"Error should not be a permission denial, got: {msg}",
);
}
}
#[gpui::test]
async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": { "main.rs": "fn main() {}" }
},
"external": {
"data": { "file.txt": "content" }
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link_to_external").as_ref(),
PathBuf::from("../external"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(DeletePathTool::new(project, action_log));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
}),
event_stream,
cx,
)
});
let auth = event_rx.expect_authorization().await;
drop(auth);
let result = task.await;
assert!(
result.is_err(),
"Tool should fail when authorization is denied"
);
}
#[gpui::test]
async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": { "main.rs": "fn main() {}" }
},
"external": {
"data": { "file.txt": "content" }
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link_to_external").as_ref(),
PathBuf::from("../external"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(DeletePathTool::new(project, action_log));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let task = cx.update(|cx| {
tool.run(
ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
}),
event_stream,
cx,
)
});
let auth = event_rx.expect_authorization().await;
let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
assert!(
title.contains("points outside the project") || title.contains("symlink"),
"Authorization title should mention symlink escape, got: {title}",
);
auth.response
.send(acp_thread::SelectedPermissionOutcome::new(
acp::PermissionOptionId::new("allow"),
acp::PermissionOptionKind::AllowOnce,
))
.unwrap();
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Expected a single authorization prompt",
);
let result = task.await;
if let Err(err) = &result {
let message = format!("{err:#}");
assert!(
!message.contains("denied") && !message.contains("authorization"),
"Error should not be a permission denial, got: {message}",
);
}
}
#[gpui::test]
async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
init_test(cx);
cx.update(|cx| {
let mut settings = AgentSettings::get_global(cx).clone();
settings.tool_permissions.tools.insert(
"delete_path".into(),
agent_settings::ToolRules {
default: Some(settings::ToolPermissionMode::Deny),
..Default::default()
},
);
AgentSettings::override_global(settings, cx);
});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/root"),
json!({
"project": {
"src": { "main.rs": "fn main() {}" }
},
"external": {
"data": { "file.txt": "content" }
}
}),
)
.await;
fs.create_symlink(
path!("/root/project/link_to_external").as_ref(),
PathBuf::from("../external"),
)
.await
.unwrap();
let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
cx.executor().run_until_parked();
let action_log = cx.new(|_| ActionLog::new(project.clone()));
let tool = Arc::new(DeletePathTool::new(project, action_log));
let (event_stream, mut event_rx) = ToolCallEventStream::test();
let result = cx
.update(|cx| {
tool.run(
ToolInput::resolved(DeletePathToolInput {
path: "project/link_to_external".into(),
}),
event_stream,
cx,
)
})
.await;
assert!(result.is_err(), "Tool should fail when policy denies");
assert!(
!matches!(
event_rx.try_recv(),
Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
),
"Deny policy should not emit symlink authorization prompt",
);
}
}