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