1use super::tool_permissions::{
2 SensitiveSettingsKind, authorize_symlink_access, canonicalize_worktree_roots,
3 detect_symlink_escape, sensitive_settings_kind,
4};
5use crate::{AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_for_path};
6use action_log::ActionLog;
7use agent_client_protocol::ToolKind;
8use agent_settings::AgentSettings;
9use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc};
10use gpui::{App, AppContext, Entity, SharedString, Task};
11use project::{Project, ProjectPath};
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use settings::Settings;
15use std::path::Path;
16use std::sync::Arc;
17use util::markdown::MarkdownInlineCode;
18
19/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion.
20#[derive(Debug, Serialize, Deserialize, JsonSchema)]
21pub struct DeletePathToolInput {
22 /// The path of the file or directory to delete.
23 ///
24 /// <example>
25 /// If the project has the following files:
26 ///
27 /// - directory1/a/something.txt
28 /// - directory2/a/things.txt
29 /// - directory3/a/other.txt
30 ///
31 /// You can delete the first file by providing a path of "directory1/a/something.txt"
32 /// </example>
33 pub path: String,
34}
35
36pub struct DeletePathTool {
37 project: Entity<Project>,
38 action_log: Entity<ActionLog>,
39}
40
41impl DeletePathTool {
42 pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
43 Self {
44 project,
45 action_log,
46 }
47 }
48}
49
50impl AgentTool for DeletePathTool {
51 type Input = DeletePathToolInput;
52 type Output = String;
53
54 const NAME: &'static str = "delete_path";
55
56 fn kind() -> ToolKind {
57 ToolKind::Delete
58 }
59
60 fn initial_title(
61 &self,
62 input: Result<Self::Input, serde_json::Value>,
63 _cx: &mut App,
64 ) -> SharedString {
65 if let Ok(input) = input {
66 format!("Delete “`{}`”", input.path).into()
67 } else {
68 "Delete path".into()
69 }
70 }
71
72 fn run(
73 self: Arc<Self>,
74 input: Self::Input,
75 event_stream: ToolCallEventStream,
76 cx: &mut App,
77 ) -> Task<Result<Self::Output, Self::Output>> {
78 let path = input.path;
79
80 let settings = AgentSettings::get_global(cx);
81 let decision = decide_permission_for_path(Self::NAME, &path, settings);
82
83 if let ToolPermissionDecision::Deny(reason) = decision {
84 return Task::ready(Err(reason));
85 }
86
87 let project = self.project.clone();
88 let action_log = self.action_log.clone();
89 cx.spawn(async move |cx| {
90 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
91 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
92
93 let symlink_escape_target = project.read_with(cx, |project, cx| {
94 detect_symlink_escape(project, &path, &canonical_roots, cx)
95 .map(|(_, target)| target)
96 });
97
98 let settings_kind = sensitive_settings_kind(Path::new(&path), fs.as_ref()).await;
99
100 let decision =
101 if matches!(decision, ToolPermissionDecision::Allow) && settings_kind.is_some() {
102 ToolPermissionDecision::Confirm
103 } else {
104 decision
105 };
106
107 let authorize = if let Some(canonical_target) = symlink_escape_target {
108 // Symlink escape authorization replaces (rather than supplements)
109 // the normal tool-permission prompt. The symlink prompt already
110 // requires explicit user approval with the canonical target shown,
111 // which is strictly more security-relevant than a generic confirm.
112 Some(cx.update(|cx| {
113 authorize_symlink_access(
114 Self::NAME,
115 &path,
116 &canonical_target,
117 &event_stream,
118 cx,
119 )
120 }))
121 } else {
122 match decision {
123 ToolPermissionDecision::Allow => None,
124 ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
125 let context =
126 crate::ToolPermissionContext::new(Self::NAME, vec![path.clone()]);
127 let title = format!("Delete {}", MarkdownInlineCode(&path));
128 let title = match settings_kind {
129 Some(SensitiveSettingsKind::Local) => {
130 format!("{title} (local settings)")
131 }
132 Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
133 None => title,
134 };
135 event_stream.authorize(title, context, cx)
136 })),
137 ToolPermissionDecision::Deny(_) => None,
138 }
139 };
140
141 if let Some(authorize) = authorize {
142 authorize.await.map_err(|e| e.to_string())?;
143 }
144
145 let (project_path, worktree_snapshot) = project.read_with(cx, |project, cx| {
146 let project_path = project.find_project_path(&path, cx).ok_or_else(|| {
147 format!("Couldn't delete {path} because that path isn't in this project.")
148 })?;
149 let worktree = project
150 .worktree_for_id(project_path.worktree_id, cx)
151 .ok_or_else(|| {
152 format!("Couldn't delete {path} because that path isn't in this project.")
153 })?;
154 let worktree_snapshot = worktree.read(cx).snapshot();
155 Result::<_, String>::Ok((project_path, worktree_snapshot))
156 })?;
157
158 let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
159 cx.background_spawn({
160 let project_path = project_path.clone();
161 async move {
162 for entry in
163 worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
164 {
165 if !entry.path.starts_with(&project_path.path) {
166 break;
167 }
168 paths_tx
169 .send(ProjectPath {
170 worktree_id: project_path.worktree_id,
171 path: entry.path.clone(),
172 })
173 .await?;
174 }
175 anyhow::Ok(())
176 }
177 })
178 .detach();
179
180 loop {
181 let path_result = futures::select! {
182 path = paths_rx.next().fuse() => path,
183 _ = event_stream.cancelled_by_user().fuse() => {
184 return Err("Delete cancelled by user".to_string());
185 }
186 };
187 let Some(path) = path_result else {
188 break;
189 };
190 if let Ok(buffer) = project
191 .update(cx, |project, cx| project.open_buffer(path, cx))
192 .await
193 {
194 action_log.update(cx, |action_log, cx| {
195 action_log.will_delete_buffer(buffer.clone(), cx)
196 });
197 }
198 }
199
200 let deletion_task = project
201 .update(cx, |project, cx| {
202 project.delete_file(project_path, false, cx)
203 })
204 .ok_or_else(|| {
205 format!("Couldn't delete {path} because that path isn't in this project.")
206 })?;
207
208 futures::select! {
209 result = deletion_task.fuse() => {
210 result.map_err(|e| format!("Deleting {path}: {e}"))?;
211 }
212 _ = event_stream.cancelled_by_user().fuse() => {
213 return Err("Delete cancelled by user".to_string());
214 }
215 }
216 Ok(format!("Deleted {path}"))
217 })
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use agent_client_protocol as acp;
225 use fs::Fs as _;
226 use gpui::TestAppContext;
227 use project::{FakeFs, Project};
228 use serde_json::json;
229 use settings::SettingsStore;
230 use std::path::PathBuf;
231 use util::path;
232
233 use crate::ToolCallEventStream;
234
235 fn init_test(cx: &mut TestAppContext) {
236 cx.update(|cx| {
237 let settings_store = SettingsStore::test(cx);
238 cx.set_global(settings_store);
239 });
240 cx.update(|cx| {
241 let mut settings = AgentSettings::get_global(cx).clone();
242 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
243 AgentSettings::override_global(settings, cx);
244 });
245 }
246
247 #[gpui::test]
248 async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
249 init_test(cx);
250
251 let fs = FakeFs::new(cx.executor());
252 fs.insert_tree(
253 path!("/root"),
254 json!({
255 "project": {
256 "src": { "main.rs": "fn main() {}" }
257 },
258 "external": {
259 "data": { "file.txt": "content" }
260 }
261 }),
262 )
263 .await;
264
265 fs.create_symlink(
266 path!("/root/project/link_to_external").as_ref(),
267 PathBuf::from("../external"),
268 )
269 .await
270 .unwrap();
271
272 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
273 cx.executor().run_until_parked();
274
275 let action_log = cx.new(|_| ActionLog::new(project.clone()));
276 let tool = Arc::new(DeletePathTool::new(project, action_log));
277
278 let (event_stream, mut event_rx) = ToolCallEventStream::test();
279 let task = cx.update(|cx| {
280 tool.run(
281 DeletePathToolInput {
282 path: "project/link_to_external".into(),
283 },
284 event_stream,
285 cx,
286 )
287 });
288
289 let auth = event_rx.expect_authorization().await;
290 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
291 assert!(
292 title.contains("points outside the project") || title.contains("symlink"),
293 "Authorization title should mention symlink escape, got: {title}",
294 );
295
296 auth.response
297 .send(acp::PermissionOptionId::new("allow"))
298 .unwrap();
299
300 let result = task.await;
301 // FakeFs cannot delete symlink entries (they are neither Dir nor File
302 // internally), so the deletion itself may fail. The important thing is
303 // that the authorization was requested and accepted — any error must
304 // come from the fs layer, not from a permission denial.
305 if let Err(err) = &result {
306 let msg = format!("{err:#}");
307 assert!(
308 !msg.contains("denied") && !msg.contains("authorization"),
309 "Error should not be a permission denial, got: {msg}",
310 );
311 }
312 }
313
314 #[gpui::test]
315 async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
316 init_test(cx);
317
318 let fs = FakeFs::new(cx.executor());
319 fs.insert_tree(
320 path!("/root"),
321 json!({
322 "project": {
323 "src": { "main.rs": "fn main() {}" }
324 },
325 "external": {
326 "data": { "file.txt": "content" }
327 }
328 }),
329 )
330 .await;
331
332 fs.create_symlink(
333 path!("/root/project/link_to_external").as_ref(),
334 PathBuf::from("../external"),
335 )
336 .await
337 .unwrap();
338
339 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
340 cx.executor().run_until_parked();
341
342 let action_log = cx.new(|_| ActionLog::new(project.clone()));
343 let tool = Arc::new(DeletePathTool::new(project, action_log));
344
345 let (event_stream, mut event_rx) = ToolCallEventStream::test();
346 let task = cx.update(|cx| {
347 tool.run(
348 DeletePathToolInput {
349 path: "project/link_to_external".into(),
350 },
351 event_stream,
352 cx,
353 )
354 });
355
356 let auth = event_rx.expect_authorization().await;
357
358 drop(auth);
359
360 let result = task.await;
361 assert!(
362 result.is_err(),
363 "Tool should fail when authorization is denied"
364 );
365 }
366
367 #[gpui::test]
368 async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
369 cx: &mut TestAppContext,
370 ) {
371 init_test(cx);
372 cx.update(|cx| {
373 let mut settings = AgentSettings::get_global(cx).clone();
374 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
375 AgentSettings::override_global(settings, cx);
376 });
377
378 let fs = FakeFs::new(cx.executor());
379 fs.insert_tree(
380 path!("/root"),
381 json!({
382 "project": {
383 "src": { "main.rs": "fn main() {}" }
384 },
385 "external": {
386 "data": { "file.txt": "content" }
387 }
388 }),
389 )
390 .await;
391
392 fs.create_symlink(
393 path!("/root/project/link_to_external").as_ref(),
394 PathBuf::from("../external"),
395 )
396 .await
397 .unwrap();
398
399 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
400 cx.executor().run_until_parked();
401
402 let action_log = cx.new(|_| ActionLog::new(project.clone()));
403 let tool = Arc::new(DeletePathTool::new(project, action_log));
404
405 let (event_stream, mut event_rx) = ToolCallEventStream::test();
406 let task = cx.update(|cx| {
407 tool.run(
408 DeletePathToolInput {
409 path: "project/link_to_external".into(),
410 },
411 event_stream,
412 cx,
413 )
414 });
415
416 let auth = event_rx.expect_authorization().await;
417 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
418 assert!(
419 title.contains("points outside the project") || title.contains("symlink"),
420 "Authorization title should mention symlink escape, got: {title}",
421 );
422
423 auth.response
424 .send(acp::PermissionOptionId::new("allow"))
425 .unwrap();
426
427 assert!(
428 !matches!(
429 event_rx.try_next(),
430 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
431 ),
432 "Expected a single authorization prompt",
433 );
434
435 let result = task.await;
436 if let Err(err) = &result {
437 let message = format!("{err:#}");
438 assert!(
439 !message.contains("denied") && !message.contains("authorization"),
440 "Error should not be a permission denial, got: {message}",
441 );
442 }
443 }
444
445 #[gpui::test]
446 async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
447 init_test(cx);
448 cx.update(|cx| {
449 let mut settings = AgentSettings::get_global(cx).clone();
450 settings.tool_permissions.tools.insert(
451 "delete_path".into(),
452 agent_settings::ToolRules {
453 default: Some(settings::ToolPermissionMode::Deny),
454 ..Default::default()
455 },
456 );
457 AgentSettings::override_global(settings, cx);
458 });
459
460 let fs = FakeFs::new(cx.executor());
461 fs.insert_tree(
462 path!("/root"),
463 json!({
464 "project": {
465 "src": { "main.rs": "fn main() {}" }
466 },
467 "external": {
468 "data": { "file.txt": "content" }
469 }
470 }),
471 )
472 .await;
473
474 fs.create_symlink(
475 path!("/root/project/link_to_external").as_ref(),
476 PathBuf::from("../external"),
477 )
478 .await
479 .unwrap();
480
481 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
482 cx.executor().run_until_parked();
483
484 let action_log = cx.new(|_| ActionLog::new(project.clone()));
485 let tool = Arc::new(DeletePathTool::new(project, action_log));
486
487 let (event_stream, mut event_rx) = ToolCallEventStream::test();
488 let result = cx
489 .update(|cx| {
490 tool.run(
491 DeletePathToolInput {
492 path: "project/link_to_external".into(),
493 },
494 event_stream,
495 cx,
496 )
497 })
498 .await;
499
500 assert!(result.is_err(), "Tool should fail when policy denies");
501 assert!(
502 !matches!(
503 event_rx.try_next(),
504 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
505 ),
506 "Deny policy should not emit symlink authorization prompt",
507 );
508 }
509}