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