Skip to repository content663 lines · 24.2 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:34:08.918Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
copy_path_tool.rs
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