Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:32:15.441Z 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

move_path_tool.rs

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