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