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