delete_path_tool.rs

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