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}