Skip to repository content689 lines · 25.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:32:15.441Z 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
move_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 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