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