Skip to repository content

tenant.openagents/omega

No repository description is available.

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

copy_path_tool.rs

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