copy_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, Task};
 12use project::Project;
 13use schemars::JsonSchema;
 14use serde::{Deserialize, Serialize};
 15use settings::Settings;
 16use std::path::Path;
 17use std::sync::Arc;
 18use util::markdown::MarkdownInlineCode;
 19
 20/// Copies a file or directory in the project, and returns confirmation that the copy succeeded.
 21/// Directory contents will be copied recursively.
 22///
 23/// This tool should be used when it's desirable to create a copy of a file or directory without modifying the original.
 24/// It's much more efficient than doing this by separately reading and then writing the file or directory's contents, so this tool should be preferred over that approach whenever copying is the goal.
 25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 26pub struct CopyPathToolInput {
 27    /// The source path of the file or directory to copy.
 28    /// If a directory is specified, its contents will be copied recursively.
 29    ///
 30    /// <example>
 31    /// If the project has the following files:
 32    ///
 33    /// - directory1/a/something.txt
 34    /// - directory2/a/things.txt
 35    /// - directory3/a/other.txt
 36    ///
 37    /// You can copy the first file by providing a source_path of "directory1/a/something.txt"
 38    /// </example>
 39    pub source_path: String,
 40    /// The destination path where the file or directory should be copied to.
 41    ///
 42    /// <example>
 43    /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", provide a destination_path of "directory2/b/copy.txt"
 44    /// </example>
 45    pub destination_path: String,
 46}
 47
 48pub struct CopyPathTool {
 49    project: Entity<Project>,
 50}
 51
 52impl CopyPathTool {
 53    pub fn new(project: Entity<Project>) -> Self {
 54        Self { project }
 55    }
 56}
 57
 58impl AgentTool for CopyPathTool {
 59    type Input = CopyPathToolInput;
 60    type Output = String;
 61
 62    const NAME: &'static str = "copy_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    ) -> ui::SharedString {
 73        if let Ok(input) = input {
 74            let src = MarkdownInlineCode(&input.source_path);
 75            let dest = MarkdownInlineCode(&input.destination_path);
 76            format!("Copy {src} to {dest}").into()
 77        } else {
 78            "Copy path".into()
 79        }
 80    }
 81
 82    fn run(
 83        self: Arc<Self>,
 84        input: ToolInput<Self::Input>,
 85        event_stream: ToolCallEventStream,
 86        cx: &mut App,
 87    ) -> Task<Result<Self::Output, Self::Output>> {
 88        let project = self.project.clone();
 89        cx.spawn(async move |cx| {
 90            let input = input
 91                .recv()
 92                .await
 93                .map_err(|e| format!("Failed to receive tool input: {e}"))?;
 94            let paths = vec![input.source_path.clone(), input.destination_path.clone()];
 95            let decision = cx.update(|cx| {
 96                decide_permission_for_paths(Self::NAME, &paths, &AgentSettings::get_global(cx))
 97            });
 98            if let ToolPermissionDecision::Deny(reason) = decision {
 99                return Err(reason);
100            }
101
102            let fs = project.read_with(cx, |project, _cx| project.fs().clone());
103            let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
104
105            let symlink_escapes: Vec<(&str, std::path::PathBuf)> =
106                project.read_with(cx, |project, cx| {
107                    collect_symlink_escapes(
108                        project,
109                        &input.source_path,
110                        &input.destination_path,
111                        &canonical_roots,
112                        cx,
113                    )
114                });
115
116            let sensitive_kind =
117                sensitive_settings_kind(Path::new(&input.source_path), fs.as_ref())
118                    .await
119                    .or(
120                        sensitive_settings_kind(Path::new(&input.destination_path), fs.as_ref())
121                            .await,
122                    );
123
124            let needs_confirmation = matches!(decision, ToolPermissionDecision::Confirm)
125                || (matches!(decision, ToolPermissionDecision::Allow) && sensitive_kind.is_some());
126
127            let authorize = if !symlink_escapes.is_empty() {
128                // Symlink escape authorization replaces (rather than supplements)
129                // the normal tool-permission prompt. The symlink prompt already
130                // requires explicit user approval with the canonical target shown,
131                // which is strictly more security-relevant than a generic confirm.
132                Some(cx.update(|cx| {
133                    authorize_symlink_escapes(Self::NAME, &symlink_escapes, &event_stream, cx)
134                }))
135            } else if needs_confirmation {
136                Some(cx.update(|cx| {
137                    let src = MarkdownInlineCode(&input.source_path);
138                    let dest = MarkdownInlineCode(&input.destination_path);
139                    let context = crate::ToolPermissionContext::new(
140                        Self::NAME,
141                        vec![input.source_path.clone(), input.destination_path.clone()],
142                    );
143                    let title = format!("Copy {src} to {dest}");
144                    let title = match sensitive_kind {
145                        Some(SensitiveSettingsKind::Local) => format!("{title} (local settings)"),
146                        Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
147                        None => title,
148                    };
149                    event_stream.authorize(title, context, cx)
150                }))
151            } else {
152                None
153            };
154
155            if let Some(authorize) = authorize {
156                authorize.await.map_err(|e| e.to_string())?;
157            }
158
159            let copy_task = project.update(cx, |project, cx| {
160                match project
161                    .find_project_path(&input.source_path, cx)
162                    .and_then(|project_path| project.entry_for_path(&project_path, cx))
163                {
164                    Some(entity) => match project.find_project_path(&input.destination_path, cx) {
165                        Some(project_path) => Ok(project.copy_entry(entity.id, project_path, cx)),
166                        None => Err(format!(
167                            "Destination path {} was outside the project.",
168                            input.destination_path
169                        )),
170                    },
171                    None => Err(format!(
172                        "Source path {} was not found in the project.",
173                        input.source_path
174                    )),
175                }
176            })?;
177
178            let result = futures::select! {
179                result = copy_task.fuse() => result,
180                _ = event_stream.cancelled_by_user().fuse() => {
181                    return Err("Copy cancelled by user".to_string());
182                }
183            };
184            result.map_err(|e| {
185                format!(
186                    "Copying {} to {}: {e}",
187                    input.source_path, input.destination_path
188                )
189            })?;
190            Ok(format!(
191                "Copied {} 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_copy_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(CopyPathTool::new(project));
251
252        let input = CopyPathToolInput {
253            source_path: "project/link_to_external".into(),
254            destination_path: "project/external_copy".into(),
255        };
256
257        let (event_stream, mut event_rx) = ToolCallEventStream::test();
258        let task = cx.update(|cx| tool.run(ToolInput::resolved(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_copy_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(CopyPathTool::new(project));
305
306        let input = CopyPathToolInput {
307            source_path: "project/link_to_external".into(),
308            destination_path: "project/external_copy".into(),
309        };
310
311        let (event_stream, mut event_rx) = ToolCallEventStream::test();
312        let task = cx.update(|cx| tool.run(ToolInput::resolved(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_copy_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(CopyPathTool::new(project));
357
358        let input = CopyPathToolInput {
359            source_path: "project/link_to_external".into(),
360            destination_path: "project/external_copy".into(),
361        };
362
363        let (event_stream, mut event_rx) = ToolCallEventStream::test();
364        let task = cx.update(|cx| tool.run(ToolInput::resolved(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_copy_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                "copy_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(CopyPathTool::new(project));
433
434        let input = CopyPathToolInput {
435            source_path: "project/link_to_external".into(),
436            destination_path: "project/external_copy".into(),
437        };
438
439        let (event_stream, mut event_rx) = ToolCallEventStream::test();
440        let result = cx
441            .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx))
442            .await;
443
444        assert!(result.is_err(), "Tool should fail when policy denies");
445        assert!(
446            !matches!(
447                event_rx.try_next(),
448                Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
449            ),
450            "Deny policy should not emit symlink authorization prompt",
451        );
452    }
453}