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