1use super::tool_permissions::{
2 SensitiveSettingsKind, authorize_symlink_escapes, canonicalize_worktree_roots,
3 collect_symlink_escapes, sensitive_settings_kind,
4};
5use crate::{
6 AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_paths,
7};
8use agent_client_protocol::ToolKind;
9use agent_settings::AgentSettings;
10use futures::FutureExt as _;
11use gpui::{App, Entity, SharedString, Task};
12use project::Project;
13use schemars::JsonSchema;
14use serde::{Deserialize, Serialize};
15use settings::Settings;
16use std::{path::Path, sync::Arc};
17use util::markdown::MarkdownInlineCode;
18
19/// Moves or rename a file or directory in the project, and returns confirmation that the move succeeded.
20///
21/// If the source and destination directories are the same, but the filename is different, this performs a rename. Otherwise, it performs a move.
22///
23/// This tool should be used when it's desirable to move or rename a file or directory without changing its contents at all.
24#[derive(Debug, Serialize, Deserialize, JsonSchema)]
25pub struct MovePathToolInput {
26 /// The source path of the file or directory to move/rename.
27 ///
28 /// <example>
29 /// If the project has the following files:
30 ///
31 /// - directory1/a/something.txt
32 /// - directory2/a/things.txt
33 /// - directory3/a/other.txt
34 ///
35 /// You can move the first file by providing a source_path of "directory1/a/something.txt"
36 /// </example>
37 pub source_path: String,
38
39 /// The destination path where the file or directory should be moved/renamed to.
40 /// If the paths are the same except for the filename, then this will be a rename.
41 ///
42 /// <example>
43 /// To move "directory1/a/something.txt" to "directory2/b/renamed.txt",
44 /// provide a destination_path of "directory2/b/renamed.txt"
45 /// </example>
46 pub destination_path: String,
47}
48
49pub struct MovePathTool {
50 project: Entity<Project>,
51}
52
53impl MovePathTool {
54 pub fn new(project: Entity<Project>) -> Self {
55 Self { project }
56 }
57}
58
59impl AgentTool for MovePathTool {
60 type Input = MovePathToolInput;
61 type Output = String;
62
63 const NAME: &'static str = "move_path";
64
65 fn kind() -> ToolKind {
66 ToolKind::Move
67 }
68
69 fn initial_title(
70 &self,
71 input: Result<Self::Input, serde_json::Value>,
72 _cx: &mut App,
73 ) -> SharedString {
74 if let Ok(input) = input {
75 let src = MarkdownInlineCode(&input.source_path);
76 let dest = MarkdownInlineCode(&input.destination_path);
77 let src_path = Path::new(&input.source_path);
78 let dest_path = Path::new(&input.destination_path);
79
80 match dest_path
81 .file_name()
82 .and_then(|os_str| os_str.to_os_string().into_string().ok())
83 {
84 Some(filename) if src_path.parent() == dest_path.parent() => {
85 let filename = MarkdownInlineCode(&filename);
86 format!("Rename {src} to {filename}").into()
87 }
88 _ => format!("Move {src} to {dest}").into(),
89 }
90 } else {
91 "Move path".into()
92 }
93 }
94
95 fn run(
96 self: Arc<Self>,
97 input: ToolInput<Self::Input>,
98 event_stream: ToolCallEventStream,
99 cx: &mut App,
100 ) -> Task<Result<Self::Output, Self::Output>> {
101 let project = self.project.clone();
102 cx.spawn(async move |cx| {
103 let input = input
104 .recv()
105 .await
106 .map_err(|e| format!("Failed to receive tool input: {e}"))?;
107 let paths = vec![input.source_path.clone(), input.destination_path.clone()];
108 let decision = cx.update(|cx| {
109 decide_permission_for_paths(Self::NAME, &paths, AgentSettings::get_global(cx))
110 });
111 if let ToolPermissionDecision::Deny(reason) = decision {
112 return Err(reason);
113 }
114
115 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
116 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
117
118 let symlink_escapes: Vec<(&str, std::path::PathBuf)> =
119 project.read_with(cx, |project, cx| {
120 collect_symlink_escapes(
121 project,
122 &input.source_path,
123 &input.destination_path,
124 &canonical_roots,
125 cx,
126 )
127 });
128
129 let sensitive_kind =
130 sensitive_settings_kind(Path::new(&input.source_path), fs.as_ref())
131 .await
132 .or(
133 sensitive_settings_kind(Path::new(&input.destination_path), fs.as_ref())
134 .await,
135 );
136
137 let needs_confirmation = matches!(decision, ToolPermissionDecision::Confirm)
138 || (matches!(decision, ToolPermissionDecision::Allow) && sensitive_kind.is_some());
139
140 let authorize = if !symlink_escapes.is_empty() {
141 // Symlink escape authorization replaces (rather than supplements)
142 // the normal tool-permission prompt. The symlink prompt already
143 // requires explicit user approval with the canonical target shown,
144 // which is strictly more security-relevant than a generic confirm.
145 Some(cx.update(|cx| {
146 authorize_symlink_escapes(Self::NAME, &symlink_escapes, &event_stream, cx)
147 }))
148 } else if needs_confirmation {
149 Some(cx.update(|cx| {
150 let src = MarkdownInlineCode(&input.source_path);
151 let dest = MarkdownInlineCode(&input.destination_path);
152 let context = crate::ToolPermissionContext::new(
153 Self::NAME,
154 vec![input.source_path.clone(), input.destination_path.clone()],
155 );
156 let title = format!("Move {src} to {dest}");
157 let title = match sensitive_kind {
158 Some(SensitiveSettingsKind::Local) => format!("{title} (local settings)"),
159 Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
160 None => title,
161 };
162 event_stream.authorize(title, context, cx)
163 }))
164 } else {
165 None
166 };
167
168 if let Some(authorize) = authorize {
169 authorize.await.map_err(|e| e.to_string())?;
170 }
171
172 let rename_task = project.update(cx, |project, cx| {
173 match project
174 .find_project_path(&input.source_path, cx)
175 .and_then(|project_path| project.entry_for_path(&project_path, cx))
176 {
177 Some(entity) => match project.find_project_path(&input.destination_path, cx) {
178 Some(project_path) => Ok(project.rename_entry(entity.id, project_path, cx)),
179 None => Err(format!(
180 "Destination path {} was outside the project.",
181 input.destination_path
182 )),
183 },
184 None => Err(format!(
185 "Source path {} was not found in the project.",
186 input.source_path
187 )),
188 }
189 })?;
190
191 futures::select! {
192 result = rename_task.fuse() => result.map_err(|e| format!("Moving {} to {}: {e}", input.source_path, input.destination_path))?,
193 _ = event_stream.cancelled_by_user().fuse() => {
194 return Err("Move cancelled by user".to_string());
195 }
196 };
197 Ok(format!(
198 "Moved {} to {}",
199 input.source_path, input.destination_path
200 ))
201 })
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use agent_client_protocol as acp;
209 use fs::Fs as _;
210 use gpui::TestAppContext;
211 use project::{FakeFs, Project};
212 use serde_json::json;
213 use settings::SettingsStore;
214 use std::path::PathBuf;
215 use util::path;
216
217 fn init_test(cx: &mut TestAppContext) {
218 cx.update(|cx| {
219 let settings_store = SettingsStore::test(cx);
220 cx.set_global(settings_store);
221 });
222 cx.update(|cx| {
223 let mut settings = AgentSettings::get_global(cx).clone();
224 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
225 AgentSettings::override_global(settings, cx);
226 });
227 }
228
229 #[gpui::test]
230 async fn test_move_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) {
231 init_test(cx);
232
233 let fs = FakeFs::new(cx.executor());
234 fs.insert_tree(
235 path!("/root"),
236 json!({
237 "project": {
238 "src": { "file.txt": "content" }
239 },
240 "external": {
241 "secret.txt": "SECRET"
242 }
243 }),
244 )
245 .await;
246
247 fs.create_symlink(
248 path!("/root/project/link_to_external").as_ref(),
249 PathBuf::from("../external"),
250 )
251 .await
252 .unwrap();
253
254 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
255 cx.executor().run_until_parked();
256
257 let tool = Arc::new(MovePathTool::new(project));
258
259 let input = MovePathToolInput {
260 source_path: "project/link_to_external".into(),
261 destination_path: "project/external_moved".into(),
262 };
263
264 let (event_stream, mut event_rx) = ToolCallEventStream::test();
265 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
266
267 let auth = event_rx.expect_authorization().await;
268 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
269 assert!(
270 title.contains("points outside the project")
271 || title.contains("symlinks outside project"),
272 "Authorization title should mention symlink escape, got: {title}",
273 );
274
275 auth.response
276 .send(acp_thread::SelectedPermissionOutcome::new(
277 acp::PermissionOptionId::new("allow"),
278 acp::PermissionOptionKind::AllowOnce,
279 ))
280 .unwrap();
281
282 let result = task.await;
283 assert!(result.is_ok(), "should succeed after approval: {result:?}");
284 }
285
286 #[gpui::test]
287 async fn test_move_path_symlink_escape_denied(cx: &mut TestAppContext) {
288 init_test(cx);
289
290 let fs = FakeFs::new(cx.executor());
291 fs.insert_tree(
292 path!("/root"),
293 json!({
294 "project": {
295 "src": { "file.txt": "content" }
296 },
297 "external": {
298 "secret.txt": "SECRET"
299 }
300 }),
301 )
302 .await;
303
304 fs.create_symlink(
305 path!("/root/project/link_to_external").as_ref(),
306 PathBuf::from("../external"),
307 )
308 .await
309 .unwrap();
310
311 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
312 cx.executor().run_until_parked();
313
314 let tool = Arc::new(MovePathTool::new(project));
315
316 let input = MovePathToolInput {
317 source_path: "project/link_to_external".into(),
318 destination_path: "project/external_moved".into(),
319 };
320
321 let (event_stream, mut event_rx) = ToolCallEventStream::test();
322 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
323
324 let auth = event_rx.expect_authorization().await;
325 drop(auth);
326
327 let result = task.await;
328 assert!(result.is_err(), "should fail when denied");
329 }
330
331 #[gpui::test]
332 async fn test_move_path_symlink_escape_confirm_requires_single_approval(
333 cx: &mut TestAppContext,
334 ) {
335 init_test(cx);
336 cx.update(|cx| {
337 let mut settings = AgentSettings::get_global(cx).clone();
338 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
339 AgentSettings::override_global(settings, cx);
340 });
341
342 let fs = FakeFs::new(cx.executor());
343 fs.insert_tree(
344 path!("/root"),
345 json!({
346 "project": {
347 "src": { "file.txt": "content" }
348 },
349 "external": {
350 "secret.txt": "SECRET"
351 }
352 }),
353 )
354 .await;
355
356 fs.create_symlink(
357 path!("/root/project/link_to_external").as_ref(),
358 PathBuf::from("../external"),
359 )
360 .await
361 .unwrap();
362
363 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
364 cx.executor().run_until_parked();
365
366 let tool = Arc::new(MovePathTool::new(project));
367
368 let input = MovePathToolInput {
369 source_path: "project/link_to_external".into(),
370 destination_path: "project/external_moved".into(),
371 };
372
373 let (event_stream, mut event_rx) = ToolCallEventStream::test();
374 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
375
376 let auth = event_rx.expect_authorization().await;
377 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
378 assert!(
379 title.contains("points outside the project")
380 || title.contains("symlinks outside project"),
381 "Authorization title should mention symlink escape, got: {title}",
382 );
383
384 auth.response
385 .send(acp_thread::SelectedPermissionOutcome::new(
386 acp::PermissionOptionId::new("allow"),
387 acp::PermissionOptionKind::AllowOnce,
388 ))
389 .unwrap();
390
391 assert!(
392 !matches!(
393 event_rx.try_recv(),
394 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
395 ),
396 "Expected a single authorization prompt",
397 );
398
399 let result = task.await;
400 assert!(
401 result.is_ok(),
402 "Tool should succeed after one authorization: {result:?}"
403 );
404 }
405
406 #[gpui::test]
407 async fn test_move_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
408 init_test(cx);
409 cx.update(|cx| {
410 let mut settings = AgentSettings::get_global(cx).clone();
411 settings.tool_permissions.tools.insert(
412 "move_path".into(),
413 agent_settings::ToolRules {
414 default: Some(settings::ToolPermissionMode::Deny),
415 ..Default::default()
416 },
417 );
418 AgentSettings::override_global(settings, cx);
419 });
420
421 let fs = FakeFs::new(cx.executor());
422 fs.insert_tree(
423 path!("/root"),
424 json!({
425 "project": {
426 "src": { "file.txt": "content" }
427 },
428 "external": {
429 "secret.txt": "SECRET"
430 }
431 }),
432 )
433 .await;
434
435 fs.create_symlink(
436 path!("/root/project/link_to_external").as_ref(),
437 PathBuf::from("../external"),
438 )
439 .await
440 .unwrap();
441
442 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
443 cx.executor().run_until_parked();
444
445 let tool = Arc::new(MovePathTool::new(project));
446
447 let input = MovePathToolInput {
448 source_path: "project/link_to_external".into(),
449 destination_path: "project/external_moved".into(),
450 };
451
452 let (event_stream, mut event_rx) = ToolCallEventStream::test();
453 let result = cx
454 .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx))
455 .await;
456
457 assert!(result.is_err(), "Tool should fail when policy denies");
458 assert!(
459 !matches!(
460 event_rx.try_recv(),
461 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
462 ),
463 "Deny policy should not emit symlink authorization prompt",
464 );
465 }
466}