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