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}