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}