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::PermissionOptionId::new("allow"))
277 .unwrap();
278
279 let result = task.await;
280 assert!(result.is_ok(), "should succeed after approval: {result:?}");
281 }
282
283 #[gpui::test]
284 async fn test_move_path_symlink_escape_denied(cx: &mut TestAppContext) {
285 init_test(cx);
286
287 let fs = FakeFs::new(cx.executor());
288 fs.insert_tree(
289 path!("/root"),
290 json!({
291 "project": {
292 "src": { "file.txt": "content" }
293 },
294 "external": {
295 "secret.txt": "SECRET"
296 }
297 }),
298 )
299 .await;
300
301 fs.create_symlink(
302 path!("/root/project/link_to_external").as_ref(),
303 PathBuf::from("../external"),
304 )
305 .await
306 .unwrap();
307
308 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
309 cx.executor().run_until_parked();
310
311 let tool = Arc::new(MovePathTool::new(project));
312
313 let input = MovePathToolInput {
314 source_path: "project/link_to_external".into(),
315 destination_path: "project/external_moved".into(),
316 };
317
318 let (event_stream, mut event_rx) = ToolCallEventStream::test();
319 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
320
321 let auth = event_rx.expect_authorization().await;
322 drop(auth);
323
324 let result = task.await;
325 assert!(result.is_err(), "should fail when denied");
326 }
327
328 #[gpui::test]
329 async fn test_move_path_symlink_escape_confirm_requires_single_approval(
330 cx: &mut TestAppContext,
331 ) {
332 init_test(cx);
333 cx.update(|cx| {
334 let mut settings = AgentSettings::get_global(cx).clone();
335 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
336 AgentSettings::override_global(settings, cx);
337 });
338
339 let fs = FakeFs::new(cx.executor());
340 fs.insert_tree(
341 path!("/root"),
342 json!({
343 "project": {
344 "src": { "file.txt": "content" }
345 },
346 "external": {
347 "secret.txt": "SECRET"
348 }
349 }),
350 )
351 .await;
352
353 fs.create_symlink(
354 path!("/root/project/link_to_external").as_ref(),
355 PathBuf::from("../external"),
356 )
357 .await
358 .unwrap();
359
360 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
361 cx.executor().run_until_parked();
362
363 let tool = Arc::new(MovePathTool::new(project));
364
365 let input = MovePathToolInput {
366 source_path: "project/link_to_external".into(),
367 destination_path: "project/external_moved".into(),
368 };
369
370 let (event_stream, mut event_rx) = ToolCallEventStream::test();
371 let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
372
373 let auth = event_rx.expect_authorization().await;
374 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
375 assert!(
376 title.contains("points outside the project")
377 || title.contains("symlinks outside project"),
378 "Authorization title should mention symlink escape, got: {title}",
379 );
380
381 auth.response
382 .send(acp::PermissionOptionId::new("allow"))
383 .unwrap();
384
385 assert!(
386 !matches!(
387 event_rx.try_next(),
388 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
389 ),
390 "Expected a single authorization prompt",
391 );
392
393 let result = task.await;
394 assert!(
395 result.is_ok(),
396 "Tool should succeed after one authorization: {result:?}"
397 );
398 }
399
400 #[gpui::test]
401 async fn test_move_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
402 init_test(cx);
403 cx.update(|cx| {
404 let mut settings = AgentSettings::get_global(cx).clone();
405 settings.tool_permissions.tools.insert(
406 "move_path".into(),
407 agent_settings::ToolRules {
408 default: Some(settings::ToolPermissionMode::Deny),
409 ..Default::default()
410 },
411 );
412 AgentSettings::override_global(settings, cx);
413 });
414
415 let fs = FakeFs::new(cx.executor());
416 fs.insert_tree(
417 path!("/root"),
418 json!({
419 "project": {
420 "src": { "file.txt": "content" }
421 },
422 "external": {
423 "secret.txt": "SECRET"
424 }
425 }),
426 )
427 .await;
428
429 fs.create_symlink(
430 path!("/root/project/link_to_external").as_ref(),
431 PathBuf::from("../external"),
432 )
433 .await
434 .unwrap();
435
436 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
437 cx.executor().run_until_parked();
438
439 let tool = Arc::new(MovePathTool::new(project));
440
441 let input = MovePathToolInput {
442 source_path: "project/link_to_external".into(),
443 destination_path: "project/external_moved".into(),
444 };
445
446 let (event_stream, mut event_rx) = ToolCallEventStream::test();
447 let result = cx
448 .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx))
449 .await;
450
451 assert!(result.is_err(), "Tool should fail when policy denies");
452 assert!(
453 !matches!(
454 event_rx.try_next(),
455 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
456 ),
457 "Deny policy should not emit symlink authorization prompt",
458 );
459 }
460}