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::schema as acp;
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() -> acp::ToolKind {
66 acp::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 fs::Fs as _;
209 use gpui::TestAppContext;
210 use project::{FakeFs, Project};
211 use serde_json::json;
212 use settings::SettingsStore;
213 use std::path::PathBuf;
214 use util::path;
215
216 fn init_test(cx: &mut TestAppContext) {
217 cx.update(|cx| {
218 let settings_store = SettingsStore::test(cx);
219 cx.set_global(settings_store);
220 });
221 cx.update(|cx| {
222 let mut settings = AgentSettings::get_global(cx).clone();
223 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
224 AgentSettings::override_global(settings, cx);
225 });
226 }
227
228 #[gpui::test]
229 async fn test_move_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) {
230 init_test(cx);
231
232 let fs = FakeFs::new(cx.executor());
233 fs.insert_tree(
234 path!("/root"),
235 json!({
236 "project": {
237 "src": { "file.txt": "content" }
238 },
239 "external": {
240 "secret.txt": "SECRET"
241 }
242 }),
243 )
244 .await;
245
246 fs.create_symlink(
247 path!("/root/project/link_to_external").as_ref(),
248 PathBuf::from("../external"),
249 )
250 .await
251 .unwrap();
252
253 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
254 cx.executor().run_until_parked();
255
256 let tool = Arc::new(MovePathTool::new(project));
257
258 let input = MovePathToolInput {
259 source_path: "project/link_to_external".into(),
260 destination_path: "project/external_moved".into(),
261 };
262
263 let (event_stream, mut event_rx) = ToolCallEventStream::test();
264 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
265
266 let auth = event_rx.expect_authorization().await;
267 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
268 assert!(
269 title.contains("points outside the project")
270 || title.contains("symlinks outside project"),
271 "Authorization title should mention symlink escape, got: {title}",
272 );
273
274 auth.response
275 .send(acp_thread::SelectedPermissionOutcome::new(
276 acp::PermissionOptionId::new("allow"),
277 acp::PermissionOptionKind::AllowOnce,
278 ))
279 .unwrap();
280
281 let result = task.await;
282 assert!(result.is_ok(), "should succeed after approval: {result:?}");
283 }
284
285 #[gpui::test]
286 async fn test_move_path_symlink_escape_denied(cx: &mut TestAppContext) {
287 init_test(cx);
288
289 let fs = FakeFs::new(cx.executor());
290 fs.insert_tree(
291 path!("/root"),
292 json!({
293 "project": {
294 "src": { "file.txt": "content" }
295 },
296 "external": {
297 "secret.txt": "SECRET"
298 }
299 }),
300 )
301 .await;
302
303 fs.create_symlink(
304 path!("/root/project/link_to_external").as_ref(),
305 PathBuf::from("../external"),
306 )
307 .await
308 .unwrap();
309
310 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
311 cx.executor().run_until_parked();
312
313 let tool = Arc::new(MovePathTool::new(project));
314
315 let input = MovePathToolInput {
316 source_path: "project/link_to_external".into(),
317 destination_path: "project/external_moved".into(),
318 };
319
320 let (event_stream, mut event_rx) = ToolCallEventStream::test();
321 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
322
323 let auth = event_rx.expect_authorization().await;
324 drop(auth);
325
326 let result = task.await;
327 assert!(result.is_err(), "should fail when denied");
328 }
329
330 #[gpui::test]
331 async fn test_move_path_symlink_escape_confirm_requires_single_approval(
332 cx: &mut TestAppContext,
333 ) {
334 init_test(cx);
335 cx.update(|cx| {
336 let mut settings = AgentSettings::get_global(cx).clone();
337 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
338 AgentSettings::override_global(settings, cx);
339 });
340
341 let fs = FakeFs::new(cx.executor());
342 fs.insert_tree(
343 path!("/root"),
344 json!({
345 "project": {
346 "src": { "file.txt": "content" }
347 },
348 "external": {
349 "secret.txt": "SECRET"
350 }
351 }),
352 )
353 .await;
354
355 fs.create_symlink(
356 path!("/root/project/link_to_external").as_ref(),
357 PathBuf::from("../external"),
358 )
359 .await
360 .unwrap();
361
362 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
363 cx.executor().run_until_parked();
364
365 let tool = Arc::new(MovePathTool::new(project));
366
367 let input = MovePathToolInput {
368 source_path: "project/link_to_external".into(),
369 destination_path: "project/external_moved".into(),
370 };
371
372 let (event_stream, mut event_rx) = ToolCallEventStream::test();
373 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
374
375 let auth = event_rx.expect_authorization().await;
376 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
377 assert!(
378 title.contains("points outside the project")
379 || title.contains("symlinks outside project"),
380 "Authorization title should mention symlink escape, got: {title}",
381 );
382
383 auth.response
384 .send(acp_thread::SelectedPermissionOutcome::new(
385 acp::PermissionOptionId::new("allow"),
386 acp::PermissionOptionKind::AllowOnce,
387 ))
388 .unwrap();
389
390 assert!(
391 !matches!(
392 event_rx.try_recv(),
393 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
394 ),
395 "Expected a single authorization prompt",
396 );
397
398 let result = task.await;
399 assert!(
400 result.is_ok(),
401 "Tool should succeed after one authorization: {result:?}"
402 );
403 }
404
405 #[gpui::test]
406 async fn test_move_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
407 init_test(cx);
408 cx.update(|cx| {
409 let mut settings = AgentSettings::get_global(cx).clone();
410 settings.tool_permissions.tools.insert(
411 "move_path".into(),
412 agent_settings::ToolRules {
413 default: Some(settings::ToolPermissionMode::Deny),
414 ..Default::default()
415 },
416 );
417 AgentSettings::override_global(settings, cx);
418 });
419
420 let fs = FakeFs::new(cx.executor());
421 fs.insert_tree(
422 path!("/root"),
423 json!({
424 "project": {
425 "src": { "file.txt": "content" }
426 },
427 "external": {
428 "secret.txt": "SECRET"
429 }
430 }),
431 )
432 .await;
433
434 fs.create_symlink(
435 path!("/root/project/link_to_external").as_ref(),
436 PathBuf::from("../external"),
437 )
438 .await
439 .unwrap();
440
441 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
442 cx.executor().run_until_parked();
443
444 let tool = Arc::new(MovePathTool::new(project));
445
446 let input = MovePathToolInput {
447 source_path: "project/link_to_external".into(),
448 destination_path: "project/external_moved".into(),
449 };
450
451 let (event_stream, mut event_rx) = ToolCallEventStream::test();
452 let result = cx
453 .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx))
454 .await;
455
456 assert!(result.is_err(), "Tool should fail when policy denies");
457 assert!(
458 !matches!(
459 event_rx.try_recv(),
460 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
461 ),
462 "Deny policy should not emit symlink authorization prompt",
463 );
464 }
465}