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::{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}