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