move_path_tool.rs

  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}