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::schema as acp;
 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() -> acp::ToolKind {
 59        acp::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 fs::Fs as _;
232    use gpui::TestAppContext;
233    use project::{FakeFs, Project};
234    use serde_json::json;
235    use settings::SettingsStore;
236    use std::path::PathBuf;
237    use util::path;
238
239    use crate::ToolCallEventStream;
240
241    fn init_test(cx: &mut TestAppContext) {
242        cx.update(|cx| {
243            let settings_store = SettingsStore::test(cx);
244            cx.set_global(settings_store);
245        });
246        cx.update(|cx| {
247            let mut settings = AgentSettings::get_global(cx).clone();
248            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
249            AgentSettings::override_global(settings, cx);
250        });
251    }
252
253    #[gpui::test]
254    async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
255        init_test(cx);
256
257        let fs = FakeFs::new(cx.executor());
258        fs.insert_tree(
259            path!("/root"),
260            json!({
261                "project": {
262                    "src": { "main.rs": "fn main() {}" }
263                },
264                "external": {
265                    "data": { "file.txt": "content" }
266                }
267            }),
268        )
269        .await;
270
271        fs.create_symlink(
272            path!("/root/project/link_to_external").as_ref(),
273            PathBuf::from("../external"),
274        )
275        .await
276        .unwrap();
277
278        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
279        cx.executor().run_until_parked();
280
281        let action_log = cx.new(|_| ActionLog::new(project.clone()));
282        let tool = Arc::new(DeletePathTool::new(project, action_log));
283
284        let (event_stream, mut event_rx) = ToolCallEventStream::test();
285        let task = cx.update(|cx| {
286            tool.run(
287                ToolInput::resolved(DeletePathToolInput {
288                    path: "project/link_to_external".into(),
289                }),
290                event_stream,
291                cx,
292            )
293        });
294
295        let auth = event_rx.expect_authorization().await;
296        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
297        assert!(
298            title.contains("points outside the project") || title.contains("symlink"),
299            "Authorization title should mention symlink escape, got: {title}",
300        );
301
302        auth.response
303            .send(acp_thread::SelectedPermissionOutcome::new(
304                acp::PermissionOptionId::new("allow"),
305                acp::PermissionOptionKind::AllowOnce,
306            ))
307            .unwrap();
308
309        let result = task.await;
310        // FakeFs cannot delete symlink entries (they are neither Dir nor File
311        // internally), so the deletion itself may fail. The important thing is
312        // that the authorization was requested and accepted — any error must
313        // come from the fs layer, not from a permission denial.
314        if let Err(err) = &result {
315            let msg = format!("{err:#}");
316            assert!(
317                !msg.contains("denied") && !msg.contains("authorization"),
318                "Error should not be a permission denial, got: {msg}",
319            );
320        }
321    }
322
323    #[gpui::test]
324    async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
325        init_test(cx);
326
327        let fs = FakeFs::new(cx.executor());
328        fs.insert_tree(
329            path!("/root"),
330            json!({
331                "project": {
332                    "src": { "main.rs": "fn main() {}" }
333                },
334                "external": {
335                    "data": { "file.txt": "content" }
336                }
337            }),
338        )
339        .await;
340
341        fs.create_symlink(
342            path!("/root/project/link_to_external").as_ref(),
343            PathBuf::from("../external"),
344        )
345        .await
346        .unwrap();
347
348        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
349        cx.executor().run_until_parked();
350
351        let action_log = cx.new(|_| ActionLog::new(project.clone()));
352        let tool = Arc::new(DeletePathTool::new(project, action_log));
353
354        let (event_stream, mut event_rx) = ToolCallEventStream::test();
355        let task = cx.update(|cx| {
356            tool.run(
357                ToolInput::resolved(DeletePathToolInput {
358                    path: "project/link_to_external".into(),
359                }),
360                event_stream,
361                cx,
362            )
363        });
364
365        let auth = event_rx.expect_authorization().await;
366
367        drop(auth);
368
369        let result = task.await;
370        assert!(
371            result.is_err(),
372            "Tool should fail when authorization is denied"
373        );
374    }
375
376    #[gpui::test]
377    async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
378        cx: &mut TestAppContext,
379    ) {
380        init_test(cx);
381        cx.update(|cx| {
382            let mut settings = AgentSettings::get_global(cx).clone();
383            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
384            AgentSettings::override_global(settings, cx);
385        });
386
387        let fs = FakeFs::new(cx.executor());
388        fs.insert_tree(
389            path!("/root"),
390            json!({
391                "project": {
392                    "src": { "main.rs": "fn main() {}" }
393                },
394                "external": {
395                    "data": { "file.txt": "content" }
396                }
397            }),
398        )
399        .await;
400
401        fs.create_symlink(
402            path!("/root/project/link_to_external").as_ref(),
403            PathBuf::from("../external"),
404        )
405        .await
406        .unwrap();
407
408        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
409        cx.executor().run_until_parked();
410
411        let action_log = cx.new(|_| ActionLog::new(project.clone()));
412        let tool = Arc::new(DeletePathTool::new(project, action_log));
413
414        let (event_stream, mut event_rx) = ToolCallEventStream::test();
415        let task = cx.update(|cx| {
416            tool.run(
417                ToolInput::resolved(DeletePathToolInput {
418                    path: "project/link_to_external".into(),
419                }),
420                event_stream,
421                cx,
422            )
423        });
424
425        let auth = event_rx.expect_authorization().await;
426        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
427        assert!(
428            title.contains("points outside the project") || title.contains("symlink"),
429            "Authorization title should mention symlink escape, got: {title}",
430        );
431
432        auth.response
433            .send(acp_thread::SelectedPermissionOutcome::new(
434                acp::PermissionOptionId::new("allow"),
435                acp::PermissionOptionKind::AllowOnce,
436            ))
437            .unwrap();
438
439        assert!(
440            !matches!(
441                event_rx.try_recv(),
442                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
443            ),
444            "Expected a single authorization prompt",
445        );
446
447        let result = task.await;
448        if let Err(err) = &result {
449            let message = format!("{err:#}");
450            assert!(
451                !message.contains("denied") && !message.contains("authorization"),
452                "Error should not be a permission denial, got: {message}",
453            );
454        }
455    }
456
457    #[gpui::test]
458    async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
459        init_test(cx);
460        cx.update(|cx| {
461            let mut settings = AgentSettings::get_global(cx).clone();
462            settings.tool_permissions.tools.insert(
463                "delete_path".into(),
464                agent_settings::ToolRules {
465                    default: Some(settings::ToolPermissionMode::Deny),
466                    ..Default::default()
467                },
468            );
469            AgentSettings::override_global(settings, cx);
470        });
471
472        let fs = FakeFs::new(cx.executor());
473        fs.insert_tree(
474            path!("/root"),
475            json!({
476                "project": {
477                    "src": { "main.rs": "fn main() {}" }
478                },
479                "external": {
480                    "data": { "file.txt": "content" }
481                }
482            }),
483        )
484        .await;
485
486        fs.create_symlink(
487            path!("/root/project/link_to_external").as_ref(),
488            PathBuf::from("../external"),
489        )
490        .await
491        .unwrap();
492
493        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
494        cx.executor().run_until_parked();
495
496        let action_log = cx.new(|_| ActionLog::new(project.clone()));
497        let tool = Arc::new(DeletePathTool::new(project, action_log));
498
499        let (event_stream, mut event_rx) = ToolCallEventStream::test();
500        let result = cx
501            .update(|cx| {
502                tool.run(
503                    ToolInput::resolved(DeletePathToolInput {
504                        path: "project/link_to_external".into(),
505                    }),
506                    event_stream,
507                    cx,
508                )
509            })
510            .await;
511
512        assert!(result.is_err(), "Tool should fail when policy denies");
513        assert!(
514            !matches!(
515                event_rx.try_recv(),
516                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
517            ),
518            "Deny policy should not emit symlink authorization prompt",
519        );
520    }
521}