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::schema as acp;
  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() -> acp::ToolKind {
 65        acp::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 fs::Fs as _;
202    use gpui::TestAppContext;
203    use project::{FakeFs, Project};
204    use serde_json::json;
205    use settings::SettingsStore;
206    use std::path::PathBuf;
207    use util::path;
208
209    fn init_test(cx: &mut TestAppContext) {
210        cx.update(|cx| {
211            let settings_store = SettingsStore::test(cx);
212            cx.set_global(settings_store);
213        });
214        cx.update(|cx| {
215            let mut settings = AgentSettings::get_global(cx).clone();
216            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
217            AgentSettings::override_global(settings, cx);
218        });
219    }
220
221    #[gpui::test]
222    async fn test_copy_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) {
223        init_test(cx);
224
225        let fs = FakeFs::new(cx.executor());
226        fs.insert_tree(
227            path!("/root"),
228            json!({
229                "project": {
230                    "src": { "file.txt": "content" }
231                },
232                "external": {
233                    "secret.txt": "SECRET"
234                }
235            }),
236        )
237        .await;
238
239        fs.create_symlink(
240            path!("/root/project/link_to_external").as_ref(),
241            PathBuf::from("../external"),
242        )
243        .await
244        .unwrap();
245
246        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
247        cx.executor().run_until_parked();
248
249        let tool = Arc::new(CopyPathTool::new(project));
250
251        let input = CopyPathToolInput {
252            source_path: "project/link_to_external".into(),
253            destination_path: "project/external_copy".into(),
254        };
255
256        let (event_stream, mut event_rx) = ToolCallEventStream::test();
257        let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
258
259        let auth = event_rx.expect_authorization().await;
260        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
261        assert!(
262            title.contains("points outside the project")
263                || title.contains("symlinks outside project"),
264            "Authorization title should mention symlink escape, got: {title}",
265        );
266
267        auth.response
268            .send(acp_thread::SelectedPermissionOutcome::new(
269                acp::PermissionOptionId::new("allow"),
270                acp::PermissionOptionKind::AllowOnce,
271            ))
272            .unwrap();
273
274        let result = task.await;
275        assert!(result.is_ok(), "should succeed after approval: {result:?}");
276    }
277
278    #[gpui::test]
279    async fn test_copy_path_symlink_escape_denied(cx: &mut TestAppContext) {
280        init_test(cx);
281
282        let fs = FakeFs::new(cx.executor());
283        fs.insert_tree(
284            path!("/root"),
285            json!({
286                "project": {
287                    "src": { "file.txt": "content" }
288                },
289                "external": {
290                    "secret.txt": "SECRET"
291                }
292            }),
293        )
294        .await;
295
296        fs.create_symlink(
297            path!("/root/project/link_to_external").as_ref(),
298            PathBuf::from("../external"),
299        )
300        .await
301        .unwrap();
302
303        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
304        cx.executor().run_until_parked();
305
306        let tool = Arc::new(CopyPathTool::new(project));
307
308        let input = CopyPathToolInput {
309            source_path: "project/link_to_external".into(),
310            destination_path: "project/external_copy".into(),
311        };
312
313        let (event_stream, mut event_rx) = ToolCallEventStream::test();
314        let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
315
316        let auth = event_rx.expect_authorization().await;
317        drop(auth);
318
319        let result = task.await;
320        assert!(result.is_err(), "should fail when denied");
321    }
322
323    #[gpui::test]
324    async fn test_copy_path_symlink_escape_confirm_requires_single_approval(
325        cx: &mut TestAppContext,
326    ) {
327        init_test(cx);
328        cx.update(|cx| {
329            let mut settings = AgentSettings::get_global(cx).clone();
330            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
331            AgentSettings::override_global(settings, cx);
332        });
333
334        let fs = FakeFs::new(cx.executor());
335        fs.insert_tree(
336            path!("/root"),
337            json!({
338                "project": {
339                    "src": { "file.txt": "content" }
340                },
341                "external": {
342                    "secret.txt": "SECRET"
343                }
344            }),
345        )
346        .await;
347
348        fs.create_symlink(
349            path!("/root/project/link_to_external").as_ref(),
350            PathBuf::from("../external"),
351        )
352        .await
353        .unwrap();
354
355        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
356        cx.executor().run_until_parked();
357
358        let tool = Arc::new(CopyPathTool::new(project));
359
360        let input = CopyPathToolInput {
361            source_path: "project/link_to_external".into(),
362            destination_path: "project/external_copy".into(),
363        };
364
365        let (event_stream, mut event_rx) = ToolCallEventStream::test();
366        let task = cx.update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx));
367
368        let auth = event_rx.expect_authorization().await;
369        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
370        assert!(
371            title.contains("points outside the project")
372                || title.contains("symlinks outside project"),
373            "Authorization title should mention symlink escape, got: {title}",
374        );
375
376        auth.response
377            .send(acp_thread::SelectedPermissionOutcome::new(
378                acp::PermissionOptionId::new("allow"),
379                acp::PermissionOptionKind::AllowOnce,
380            ))
381            .unwrap();
382
383        assert!(
384            !matches!(
385                event_rx.try_recv(),
386                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
387            ),
388            "Expected a single authorization prompt",
389        );
390
391        let result = task.await;
392        assert!(
393            result.is_ok(),
394            "Tool should succeed after one authorization: {result:?}"
395        );
396    }
397
398    #[gpui::test]
399    async fn test_copy_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
400        init_test(cx);
401        cx.update(|cx| {
402            let mut settings = AgentSettings::get_global(cx).clone();
403            settings.tool_permissions.tools.insert(
404                "copy_path".into(),
405                agent_settings::ToolRules {
406                    default: Some(settings::ToolPermissionMode::Deny),
407                    ..Default::default()
408                },
409            );
410            AgentSettings::override_global(settings, cx);
411        });
412
413        let fs = FakeFs::new(cx.executor());
414        fs.insert_tree(
415            path!("/root"),
416            json!({
417                "project": {
418                    "src": { "file.txt": "content" }
419                },
420                "external": {
421                    "secret.txt": "SECRET"
422                }
423            }),
424        )
425        .await;
426
427        fs.create_symlink(
428            path!("/root/project/link_to_external").as_ref(),
429            PathBuf::from("../external"),
430        )
431        .await
432        .unwrap();
433
434        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
435        cx.executor().run_until_parked();
436
437        let tool = Arc::new(CopyPathTool::new(project));
438
439        let input = CopyPathToolInput {
440            source_path: "project/link_to_external".into(),
441            destination_path: "project/external_copy".into(),
442        };
443
444        let (event_stream, mut event_rx) = ToolCallEventStream::test();
445        let result = cx
446            .update(|cx| tool.run(ToolInput::resolved(input), event_stream, cx))
447            .await;
448
449        assert!(result.is_err(), "Tool should fail when policy denies");
450        assert!(
451            !matches!(
452                event_rx.try_recv(),
453                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
454            ),
455            "Deny policy should not emit symlink authorization prompt",
456        );
457    }
458}