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 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}