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