Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:30:31.830Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

delete_path_tool.rs

708 lines · 25.4 KB · rust
1use super::tool_permissions::{
2    authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape,
3    resolve_global_skill_descendant_path, resolves_to_global_skills_dir, sensitive_settings_kind,
4};
5use crate::{
6    AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
7    authorize_with_sensitive_settings, decide_permission_for_path,
8};
9use action_log::ActionLog;
10use agent_client_protocol::schema::v1 as acp;
11use agent_settings::AgentSettings;
12use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc};
13use gpui::{App, AppContext, Entity, SharedString, Task};
14use project::{Project, ProjectPath};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use settings::Settings;
18use std::path::Path;
19use std::sync::Arc;
20use util::markdown::MarkdownInlineCode;
21
22/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion.
23///
24/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills.
25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26pub struct DeletePathToolInput {
27    /// The path of the file or directory to delete.
28    ///
29    /// <example>
30    /// If the project has the following files:
31    ///
32    /// - directory1/a/something.txt
33    /// - directory2/a/things.txt
34    /// - directory3/a/other.txt
35    ///
36    /// You can delete the first file by providing a path of "directory1/a/something.txt"
37    /// </example>
38    pub path: String,
39}
40
41pub struct DeletePathTool {
42    project: Entity<Project>,
43    action_log: Entity<ActionLog>,
44}
45
46impl DeletePathTool {
47    pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
48        Self {
49            project,
50            action_log,
51        }
52    }
53}
54
55impl AgentTool for DeletePathTool {
56    type Input = DeletePathToolInput;
57    type Output = String;
58
59    const NAME: &'static str = "delete_path";
60
61    fn kind() -> acp::ToolKind {
62        acp::ToolKind::Delete
63    }
64
65    fn initial_title(
66        &self,
67        input: Result<Self::Input, serde_json::Value>,
68        _cx: &mut App,
69    ) -> SharedString {
70        if let Ok(input) = input {
71            format!("Delete “`{}`”", input.path).into()
72        } else {
73            "Delete path".into()
74        }
75    }
76
77    fn run(
78        self: Arc<Self>,
79        input: ToolInput<Self::Input>,
80        event_stream: ToolCallEventStream,
81        cx: &mut App,
82    ) -> Task<Result<Self::Output, Self::Output>> {
83        let project = self.project.clone();
84        let action_log = self.action_log.clone();
85        cx.spawn(async move |cx| {
86            let input = input.recv().await.map_err(|e| e.to_string())?;
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            if resolves_to_global_skills_dir(Path::new(&path), fs.as_ref()).await {
101                return Err(
102                    "Cannot delete the global agent skills directory itself. Delete a skill directory or file beneath it instead."
103                        .to_string(),
104                );
105            }
106
107            let global_skill_path =
108                resolve_global_skill_descendant_path(Path::new(&path), fs.as_ref()).await;
109
110            let symlink_escape_target = project.read_with(cx, |project, cx| {
111                detect_symlink_escape(project, &path, &canonical_roots, cx)
112                    .map(|(_, target)| target)
113            });
114
115            let settings_kind =
116                sensitive_settings_kind(Path::new(&path), &canonical_roots, fs.as_ref()).await;
117
118            let decision =
119                if matches!(decision, ToolPermissionDecision::Allow) && settings_kind.is_some() {
120                    ToolPermissionDecision::Confirm
121                } else {
122                    decision
123                };
124
125            let authorize = if let Some(canonical_target) = symlink_escape_target {
126                // Symlink escape authorization replaces (rather than supplements)
127                // the normal tool-permission prompt. The symlink prompt already
128                // requires explicit user approval with the canonical target shown,
129                // which is strictly more security-relevant than a generic confirm.
130                Some(cx.update(|cx| {
131                    authorize_symlink_access(
132                        Self::NAME,
133                        &path,
134                        &canonical_target,
135                        &event_stream,
136                        cx,
137                    )
138                }))
139            } else {
140                match decision {
141                    ToolPermissionDecision::Allow => None,
142                    ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
143                        let context =
144                            crate::ToolPermissionContext::new(Self::NAME, vec![path.clone()]);
145                        let title = format!("Delete {}", MarkdownInlineCode(&path));
146                        authorize_with_sensitive_settings(
147                            settings_kind,
148                            context,
149                            &title,
150                            &event_stream,
151                            cx,
152                        )
153                    })),
154                    ToolPermissionDecision::Deny(_) => None,
155                }
156            };
157
158            if let Some(authorize) = authorize {
159                authorize.await.map_err(|e| e.to_string())?;
160            }
161
162            if let Some(global_skill_path) = global_skill_path {
163                let metadata = fs
164                    .metadata(&global_skill_path)
165                    .await
166                    .map_err(|e| format!("Deleting {path}: {e}"))?
167                    .ok_or_else(|| format!("Deleting {path}: path not found"))?;
168
169                futures::select! {
170                    result = async {
171                        if metadata.is_dir {
172                            fs.remove_dir(
173                                &global_skill_path,
174                                fs::RemoveOptions {
175                                    recursive: true,
176                                    ..fs::RemoveOptions::default()
177                                },
178                            )
179                            .await
180                        } else {
181                            fs.remove_file(&global_skill_path, fs::RemoveOptions::default()).await
182                        }
183                    }.fuse() => {
184                        result.map_err(|e| format!("Deleting {path}: {e}"))?;
185                    }
186                    _ = event_stream.cancelled_by_user().fuse() => {
187                        return Err("Delete cancelled by user".to_string());
188                    }
189                }
190
191                return Ok(format!("Deleted {path}"));
192            }
193
194            let (project_path, worktree_snapshot) = project.read_with(cx, |project, cx| {
195                let project_path = project.find_project_path(&path, cx).ok_or_else(|| {
196                    format!("Couldn't delete {path} because that path isn't in this project.")
197                })?;
198                let worktree = project
199                    .worktree_for_id(project_path.worktree_id, cx)
200                    .ok_or_else(|| {
201                        format!("Couldn't delete {path} because that path isn't in this project.")
202                    })?;
203                let worktree_snapshot = worktree.read(cx).snapshot();
204                Result::<_, String>::Ok((project_path, worktree_snapshot))
205            })?;
206
207            let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
208            cx.background_spawn({
209                let project_path = project_path.clone();
210                async move {
211                    for entry in
212                        worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
213                    {
214                        if !entry.path.starts_with(&project_path.path) {
215                            break;
216                        }
217                        paths_tx
218                            .send(ProjectPath {
219                                worktree_id: project_path.worktree_id,
220                                path: entry.path.clone(),
221                            })
222                            .await?;
223                    }
224                    anyhow::Ok(())
225                }
226            })
227            .detach();
228
229            loop {
230                let path_result = futures::select! {
231                    path = paths_rx.next().fuse() => path,
232                    _ = event_stream.cancelled_by_user().fuse() => {
233                        return Err("Delete cancelled by user".to_string());
234                    }
235                };
236                let Some(path) = path_result else {
237                    break;
238                };
239                if let Ok(buffer) = project
240                    .update(cx, |project, cx| project.open_buffer(path, cx))
241                    .await
242                {
243                    action_log.update(cx, |action_log, cx| {
244                        action_log.will_delete_buffer(buffer.clone(), cx)
245                    });
246                }
247            }
248
249            let deletion_task = project
250                .update(cx, |project, cx| project.delete_file(project_path, cx))
251                .ok_or_else(|| {
252                    format!("Couldn't delete {path} because that path isn't in this project.")
253                })?;
254
255            futures::select! {
256                result = deletion_task.fuse() => {
257                    result.map_err(|e| format!("Deleting {path}: {e}"))?;
258                }
259                _ = event_stream.cancelled_by_user().fuse() => {
260                    return Err("Delete cancelled by user".to_string());
261                }
262            }
263            Ok(format!("Deleted {path}"))
264        })
265    }
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use fs::Fs as _;
272    use gpui::TestAppContext;
273    use project::{FakeFs, Project};
274    use serde_json::json;
275    use settings::SettingsStore;
276    use std::path::PathBuf;
277    use util::path;
278
279    use crate::ToolCallEventStream;
280
281    fn init_test(cx: &mut TestAppContext) {
282        cx.update(|cx| {
283            let settings_store = SettingsStore::test(cx);
284            cx.set_global(settings_store);
285        });
286        cx.update(|cx| {
287            let mut settings = AgentSettings::get_global(cx).clone();
288            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
289            AgentSettings::override_global(settings, cx);
290        });
291    }
292
293    #[gpui::test]
294    async fn test_delete_path_global_skill_directory(cx: &mut TestAppContext) {
295        init_test(cx);
296
297        let fs = FakeFs::new(cx.executor());
298        fs.insert_tree(path!("/root/project"), json!({})).await;
299        let skills_dir = agent_skills::global_skills_dir();
300        let skill_dir = skills_dir.join("my-skill");
301        fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" }))
302            .await;
303        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
304        cx.executor().run_until_parked();
305
306        let action_log = cx.new(|_| ActionLog::new(project.clone()));
307        let tool = Arc::new(DeletePathTool::new(project, action_log));
308        let input_path = PathBuf::from("~")
309            .join(".agents")
310            .join("skills")
311            .join("my-skill")
312            .to_string_lossy()
313            .into_owned();
314
315        let (event_stream, mut event_rx) = ToolCallEventStream::test();
316        let task = cx.update(|cx| {
317            tool.run(
318                ToolInput::resolved(DeletePathToolInput { path: input_path }),
319                event_stream,
320                cx,
321            )
322        });
323
324        let auth = event_rx.expect_authorization().await;
325        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
326        assert!(
327            title.contains("agent skills"),
328            "Authorization title should mention agent skills, got: {title}",
329        );
330        assert!(
331            auth.options
332                .first_option_of_kind(acp::PermissionOptionKind::AllowAlways)
333                .is_none(),
334            "agent skills prompt must not offer an \"Always allow\" option: {:?}",
335            auth.options,
336        );
337        auth.response
338            .send(acp_thread::SelectedPermissionOutcome::new(
339                acp::PermissionOptionId::new("allow"),
340                acp::PermissionOptionKind::AllowOnce,
341            ))
342            .expect("authorization response should send");
343
344        let result = task.await;
345        assert!(result.is_ok(), "should delete after approval: {result:?}");
346        assert!(fs.is_dir(&skills_dir).await);
347        assert!(!fs.is_dir(&skill_dir).await);
348    }
349
350    #[gpui::test]
351    async fn test_delete_path_global_skill_file(cx: &mut TestAppContext) {
352        init_test(cx);
353
354        let fs = FakeFs::new(cx.executor());
355        fs.insert_tree(path!("/root/project"), json!({})).await;
356        let skill_file = agent_skills::global_skills_dir()
357            .join("my-skill")
358            .join("references")
359            .join("notes.md");
360        fs.create_dir(skill_file.parent().unwrap()).await.unwrap();
361        fs.insert_file(&skill_file, b"notes".to_vec()).await;
362        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
363        cx.executor().run_until_parked();
364
365        let action_log = cx.new(|_| ActionLog::new(project.clone()));
366        let tool = Arc::new(DeletePathTool::new(project, action_log));
367        let input_path = PathBuf::from("~")
368            .join(".agents")
369            .join("skills")
370            .join("my-skill")
371            .join("references")
372            .join("notes.md")
373            .to_string_lossy()
374            .into_owned();
375
376        let (event_stream, mut event_rx) = ToolCallEventStream::test();
377        let task = cx.update(|cx| {
378            tool.run(
379                ToolInput::resolved(DeletePathToolInput { path: input_path }),
380                event_stream,
381                cx,
382            )
383        });
384
385        let auth = event_rx.expect_authorization().await;
386        auth.response
387            .send(acp_thread::SelectedPermissionOutcome::new(
388                acp::PermissionOptionId::new("allow"),
389                acp::PermissionOptionKind::AllowOnce,
390            ))
391            .expect("authorization response should send");
392
393        let result = task.await;
394        assert!(result.is_ok(), "should delete after approval: {result:?}");
395        assert!(!fs.is_file(&skill_file).await);
396    }
397
398    #[gpui::test]
399    async fn test_delete_path_rejects_global_skills_root(cx: &mut TestAppContext) {
400        init_test(cx);
401
402        let fs = FakeFs::new(cx.executor());
403        fs.insert_tree(path!("/root/project"), json!({})).await;
404        let skills_dir = agent_skills::global_skills_dir();
405        fs.create_dir(&skills_dir).await.unwrap();
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        let input_path = PathBuf::from("~")
412            .join(".agents")
413            .join("skills")
414            .to_string_lossy()
415            .into_owned();
416
417        let (event_stream, mut event_rx) = ToolCallEventStream::test();
418        let result = cx
419            .update(|cx| {
420                tool.run(
421                    ToolInput::resolved(DeletePathToolInput { path: input_path }),
422                    event_stream,
423                    cx,
424                )
425            })
426            .await;
427
428        assert!(result.is_err(), "should reject deleting skills root");
429        assert!(fs.is_dir(&skills_dir).await);
430        assert!(
431            !matches!(
432                event_rx.try_recv(),
433                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
434            ),
435            "Deleting the skills root should fail before requesting authorization",
436        );
437    }
438
439    #[gpui::test]
440    async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
441        init_test(cx);
442
443        let fs = FakeFs::new(cx.executor());
444        fs.insert_tree(
445            path!("/root"),
446            json!({
447                "project": {
448                    "src": { "main.rs": "fn main() {}" }
449                },
450                "external": {
451                    "data": { "file.txt": "content" }
452                }
453            }),
454        )
455        .await;
456
457        fs.create_symlink(
458            path!("/root/project/link_to_external").as_ref(),
459            PathBuf::from("../external"),
460        )
461        .await
462        .unwrap();
463
464        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
465        cx.executor().run_until_parked();
466
467        let action_log = cx.new(|_| ActionLog::new(project.clone()));
468        let tool = Arc::new(DeletePathTool::new(project, action_log));
469
470        let (event_stream, mut event_rx) = ToolCallEventStream::test();
471        let task = cx.update(|cx| {
472            tool.run(
473                ToolInput::resolved(DeletePathToolInput {
474                    path: "project/link_to_external".into(),
475                }),
476                event_stream,
477                cx,
478            )
479        });
480
481        let auth = event_rx.expect_authorization().await;
482        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
483        assert!(
484            title.contains("points outside the project") || title.contains("symlink"),
485            "Authorization title should mention symlink escape, got: {title}",
486        );
487
488        auth.response
489            .send(acp_thread::SelectedPermissionOutcome::new(
490                acp::PermissionOptionId::new("allow"),
491                acp::PermissionOptionKind::AllowOnce,
492            ))
493            .unwrap();
494
495        let result = task.await;
496        // FakeFs cannot delete symlink entries (they are neither Dir nor File
497        // internally), so the deletion itself may fail. The important thing is
498        // that the authorization was requested and accepted — any error must
499        // come from the fs layer, not from a permission denial.
500        if let Err(err) = &result {
501            let msg = format!("{err:#}");
502            assert!(
503                !msg.contains("denied") && !msg.contains("authorization"),
504                "Error should not be a permission denial, got: {msg}",
505            );
506        }
507    }
508
509    #[gpui::test]
510    async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
511        init_test(cx);
512
513        let fs = FakeFs::new(cx.executor());
514        fs.insert_tree(
515            path!("/root"),
516            json!({
517                "project": {
518                    "src": { "main.rs": "fn main() {}" }
519                },
520                "external": {
521                    "data": { "file.txt": "content" }
522                }
523            }),
524        )
525        .await;
526
527        fs.create_symlink(
528            path!("/root/project/link_to_external").as_ref(),
529            PathBuf::from("../external"),
530        )
531        .await
532        .unwrap();
533
534        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
535        cx.executor().run_until_parked();
536
537        let action_log = cx.new(|_| ActionLog::new(project.clone()));
538        let tool = Arc::new(DeletePathTool::new(project, action_log));
539
540        let (event_stream, mut event_rx) = ToolCallEventStream::test();
541        let task = cx.update(|cx| {
542            tool.run(
543                ToolInput::resolved(DeletePathToolInput {
544                    path: "project/link_to_external".into(),
545                }),
546                event_stream,
547                cx,
548            )
549        });
550
551        let auth = event_rx.expect_authorization().await;
552
553        drop(auth);
554
555        let result = task.await;
556        assert!(
557            result.is_err(),
558            "Tool should fail when authorization is denied"
559        );
560    }
561
562    #[gpui::test]
563    async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
564        cx: &mut TestAppContext,
565    ) {
566        init_test(cx);
567        cx.update(|cx| {
568            let mut settings = AgentSettings::get_global(cx).clone();
569            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
570            AgentSettings::override_global(settings, cx);
571        });
572
573        let fs = FakeFs::new(cx.executor());
574        fs.insert_tree(
575            path!("/root"),
576            json!({
577                "project": {
578                    "src": { "main.rs": "fn main() {}" }
579                },
580                "external": {
581                    "data": { "file.txt": "content" }
582                }
583            }),
584        )
585        .await;
586
587        fs.create_symlink(
588            path!("/root/project/link_to_external").as_ref(),
589            PathBuf::from("../external"),
590        )
591        .await
592        .unwrap();
593
594        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
595        cx.executor().run_until_parked();
596
597        let action_log = cx.new(|_| ActionLog::new(project.clone()));
598        let tool = Arc::new(DeletePathTool::new(project, action_log));
599
600        let (event_stream, mut event_rx) = ToolCallEventStream::test();
601        let task = cx.update(|cx| {
602            tool.run(
603                ToolInput::resolved(DeletePathToolInput {
604                    path: "project/link_to_external".into(),
605                }),
606                event_stream,
607                cx,
608            )
609        });
610
611        let auth = event_rx.expect_authorization().await;
612        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
613        assert!(
614            title.contains("points outside the project") || title.contains("symlink"),
615            "Authorization title should mention symlink escape, got: {title}",
616        );
617
618        auth.response
619            .send(acp_thread::SelectedPermissionOutcome::new(
620                acp::PermissionOptionId::new("allow"),
621                acp::PermissionOptionKind::AllowOnce,
622            ))
623            .unwrap();
624
625        assert!(
626            !matches!(
627                event_rx.try_recv(),
628                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
629            ),
630            "Expected a single authorization prompt",
631        );
632
633        let result = task.await;
634        if let Err(err) = &result {
635            let message = format!("{err:#}");
636            assert!(
637                !message.contains("denied") && !message.contains("authorization"),
638                "Error should not be a permission denial, got: {message}",
639            );
640        }
641    }
642
643    #[gpui::test]
644    async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
645        init_test(cx);
646        cx.update(|cx| {
647            let mut settings = AgentSettings::get_global(cx).clone();
648            settings.tool_permissions.tools.insert(
649                "delete_path".into(),
650                agent_settings::ToolRules {
651                    default: Some(settings::ToolPermissionMode::Deny),
652                    ..Default::default()
653                },
654            );
655            AgentSettings::override_global(settings, cx);
656        });
657
658        let fs = FakeFs::new(cx.executor());
659        fs.insert_tree(
660            path!("/root"),
661            json!({
662                "project": {
663                    "src": { "main.rs": "fn main() {}" }
664                },
665                "external": {
666                    "data": { "file.txt": "content" }
667                }
668            }),
669        )
670        .await;
671
672        fs.create_symlink(
673            path!("/root/project/link_to_external").as_ref(),
674            PathBuf::from("../external"),
675        )
676        .await
677        .unwrap();
678
679        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
680        cx.executor().run_until_parked();
681
682        let action_log = cx.new(|_| ActionLog::new(project.clone()));
683        let tool = Arc::new(DeletePathTool::new(project, action_log));
684
685        let (event_stream, mut event_rx) = ToolCallEventStream::test();
686        let result = cx
687            .update(|cx| {
688                tool.run(
689                    ToolInput::resolved(DeletePathToolInput {
690                        path: "project/link_to_external".into(),
691                    }),
692                    event_stream,
693                    cx,
694                )
695            })
696            .await;
697
698        assert!(result.is_err(), "Tool should fail when policy denies");
699        assert!(
700            !matches!(
701                event_rx.try_recv(),
702                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
703            ),
704            "Deny policy should not emit symlink authorization prompt",
705        );
706    }
707}
708
Served at tenant.openagents/omega Member data and write actions are omitted.