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 anyhow::{Context as _, Result, anyhow};
9use futures::FutureExt as _;
10use gpui::{App, Entity, Task};
11use project::Project;
12use schemars::JsonSchema;
13use serde::{Deserialize, Serialize};
14use settings::Settings;
15use std::path::Path;
16use std::sync::Arc;
17use util::markdown::MarkdownInlineCode;
18
19/// Copies a file or directory in the project, and returns confirmation that the copy succeeded.
20/// Directory contents will be copied recursively.
21///
22/// This tool should be used when it's desirable to create a copy of a file or directory without modifying the original.
23/// 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.
24#[derive(Debug, Serialize, Deserialize, JsonSchema)]
25pub struct CopyPathToolInput {
26 /// The source path of the file or directory to copy.
27 /// If a directory is specified, its contents will be copied recursively.
28 ///
29 /// <example>
30 /// If the project has the following files:
31 ///
32 /// - directory1/a/something.txt
33 /// - directory2/a/things.txt
34 /// - directory3/a/other.txt
35 ///
36 /// You can copy the first file by providing a source_path of "directory1/a/something.txt"
37 /// </example>
38 pub source_path: String,
39 /// The destination path where the file or directory should be copied to.
40 ///
41 /// <example>
42 /// To copy "directory1/a/something.txt" to "directory2/b/copy.txt", provide a destination_path of "directory2/b/copy.txt"
43 /// </example>
44 pub destination_path: String,
45}
46
47pub struct CopyPathTool {
48 project: Entity<Project>,
49}
50
51impl CopyPathTool {
52 pub fn new(project: Entity<Project>) -> Self {
53 Self { project }
54 }
55}
56
57impl AgentTool for CopyPathTool {
58 type Input = CopyPathToolInput;
59 type Output = String;
60
61 const NAME: &'static str = "copy_path";
62
63 fn kind() -> ToolKind {
64 ToolKind::Move
65 }
66
67 fn initial_title(
68 &self,
69 input: Result<Self::Input, serde_json::Value>,
70 _cx: &mut App,
71 ) -> ui::SharedString {
72 if let Ok(input) = input {
73 let src = MarkdownInlineCode(&input.source_path);
74 let dest = MarkdownInlineCode(&input.destination_path);
75 format!("Copy {src} to {dest}").into()
76 } else {
77 "Copy path".into()
78 }
79 }
80
81 fn run(
82 self: Arc<Self>,
83 input: Self::Input,
84 event_stream: ToolCallEventStream,
85 cx: &mut App,
86 ) -> Task<Result<Self::Output>> {
87 let settings = AgentSettings::get_global(cx);
88 let paths = vec![input.source_path.clone(), input.destination_path.clone()];
89 let decision = decide_permission_for_paths(Self::NAME, &paths, settings);
90 if let ToolPermissionDecision::Deny(reason) = decision {
91 return Task::ready(Err(anyhow!("{}", reason)));
92 }
93
94 let project = self.project.clone();
95 cx.spawn(async move |cx| {
96 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
97 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
98
99 let symlink_escapes: Vec<(&str, std::path::PathBuf)> =
100 project.read_with(cx, |project, cx| {
101 collect_symlink_escapes(
102 project,
103 &input.source_path,
104 &input.destination_path,
105 &canonical_roots,
106 cx,
107 )
108 });
109
110 let sensitive_kind =
111 sensitive_settings_kind(Path::new(&input.source_path), fs.as_ref())
112 .await
113 .or(
114 sensitive_settings_kind(Path::new(&input.destination_path), fs.as_ref())
115 .await,
116 );
117
118 let needs_confirmation = matches!(decision, ToolPermissionDecision::Confirm)
119 || (matches!(decision, ToolPermissionDecision::Allow) && sensitive_kind.is_some());
120
121 let authorize = if !symlink_escapes.is_empty() {
122 // Symlink escape authorization replaces (rather than supplements)
123 // the normal tool-permission prompt. The symlink prompt already
124 // requires explicit user approval with the canonical target shown,
125 // which is strictly more security-relevant than a generic confirm.
126 Some(cx.update(|cx| {
127 authorize_symlink_escapes(Self::NAME, &symlink_escapes, &event_stream, cx)
128 }))
129 } else if needs_confirmation {
130 Some(cx.update(|cx| {
131 let src = MarkdownInlineCode(&input.source_path);
132 let dest = MarkdownInlineCode(&input.destination_path);
133 let context = crate::ToolPermissionContext::new(
134 Self::NAME,
135 vec![input.source_path.clone(), input.destination_path.clone()],
136 );
137 let title = format!("Copy {src} to {dest}");
138 let title = match sensitive_kind {
139 Some(SensitiveSettingsKind::Local) => format!("{title} (local settings)"),
140 Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
141 None => title,
142 };
143 event_stream.authorize(title, context, cx)
144 }))
145 } else {
146 None
147 };
148
149 if let Some(authorize) = authorize {
150 authorize.await?;
151 }
152
153 let copy_task = project.update(cx, |project, cx| {
154 match project
155 .find_project_path(&input.source_path, cx)
156 .and_then(|project_path| project.entry_for_path(&project_path, cx))
157 {
158 Some(entity) => match project.find_project_path(&input.destination_path, cx) {
159 Some(project_path) => Ok(project.copy_entry(entity.id, project_path, cx)),
160 None => Err(anyhow!(
161 "Destination path {} was outside the project.",
162 input.destination_path
163 )),
164 },
165 None => Err(anyhow!(
166 "Source path {} was not found in the project.",
167 input.source_path
168 )),
169 }
170 })?;
171
172 let result = futures::select! {
173 result = copy_task.fuse() => result,
174 _ = event_stream.cancelled_by_user().fuse() => {
175 anyhow::bail!("Copy cancelled by user");
176 }
177 };
178 result.with_context(|| {
179 format!(
180 "Copying {} to {}",
181 input.source_path, input.destination_path
182 )
183 })?;
184 Ok(format!(
185 "Copied {} to {}",
186 input.source_path, input.destination_path
187 ))
188 })
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use agent_client_protocol as acp;
196 use fs::Fs as _;
197 use gpui::TestAppContext;
198 use project::{FakeFs, Project};
199 use serde_json::json;
200 use settings::SettingsStore;
201 use std::path::PathBuf;
202 use util::path;
203
204 fn init_test(cx: &mut TestAppContext) {
205 cx.update(|cx| {
206 let settings_store = SettingsStore::test(cx);
207 cx.set_global(settings_store);
208 });
209 cx.update(|cx| {
210 let mut settings = AgentSettings::get_global(cx).clone();
211 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
212 AgentSettings::override_global(settings, cx);
213 });
214 }
215
216 #[gpui::test]
217 async fn test_copy_path_symlink_escape_source_requests_authorization(cx: &mut TestAppContext) {
218 init_test(cx);
219
220 let fs = FakeFs::new(cx.executor());
221 fs.insert_tree(
222 path!("/root"),
223 json!({
224 "project": {
225 "src": { "file.txt": "content" }
226 },
227 "external": {
228 "secret.txt": "SECRET"
229 }
230 }),
231 )
232 .await;
233
234 fs.create_symlink(
235 path!("/root/project/link_to_external").as_ref(),
236 PathBuf::from("../external"),
237 )
238 .await
239 .unwrap();
240
241 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
242 cx.executor().run_until_parked();
243
244 let tool = Arc::new(CopyPathTool::new(project));
245
246 let input = CopyPathToolInput {
247 source_path: "project/link_to_external".into(),
248 destination_path: "project/external_copy".into(),
249 };
250
251 let (event_stream, mut event_rx) = ToolCallEventStream::test();
252 let task = cx.update(|cx| tool.run(input, event_stream, cx));
253
254 let auth = event_rx.expect_authorization().await;
255 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
256 assert!(
257 title.contains("points outside the project")
258 || title.contains("symlinks outside project"),
259 "Authorization title should mention symlink escape, got: {title}",
260 );
261
262 auth.response
263 .send(acp::PermissionOptionId::new("allow"))
264 .unwrap();
265
266 let result = task.await;
267 assert!(result.is_ok(), "should succeed after approval: {result:?}");
268 }
269
270 #[gpui::test]
271 async fn test_copy_path_symlink_escape_denied(cx: &mut TestAppContext) {
272 init_test(cx);
273
274 let fs = FakeFs::new(cx.executor());
275 fs.insert_tree(
276 path!("/root"),
277 json!({
278 "project": {
279 "src": { "file.txt": "content" }
280 },
281 "external": {
282 "secret.txt": "SECRET"
283 }
284 }),
285 )
286 .await;
287
288 fs.create_symlink(
289 path!("/root/project/link_to_external").as_ref(),
290 PathBuf::from("../external"),
291 )
292 .await
293 .unwrap();
294
295 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
296 cx.executor().run_until_parked();
297
298 let tool = Arc::new(CopyPathTool::new(project));
299
300 let input = CopyPathToolInput {
301 source_path: "project/link_to_external".into(),
302 destination_path: "project/external_copy".into(),
303 };
304
305 let (event_stream, mut event_rx) = ToolCallEventStream::test();
306 let task = cx.update(|cx| tool.run(input, event_stream, cx));
307
308 let auth = event_rx.expect_authorization().await;
309 drop(auth);
310
311 let result = task.await;
312 assert!(result.is_err(), "should fail when denied");
313 }
314
315 #[gpui::test]
316 async fn test_copy_path_symlink_escape_confirm_requires_single_approval(
317 cx: &mut TestAppContext,
318 ) {
319 init_test(cx);
320 cx.update(|cx| {
321 let mut settings = AgentSettings::get_global(cx).clone();
322 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
323 AgentSettings::override_global(settings, cx);
324 });
325
326 let fs = FakeFs::new(cx.executor());
327 fs.insert_tree(
328 path!("/root"),
329 json!({
330 "project": {
331 "src": { "file.txt": "content" }
332 },
333 "external": {
334 "secret.txt": "SECRET"
335 }
336 }),
337 )
338 .await;
339
340 fs.create_symlink(
341 path!("/root/project/link_to_external").as_ref(),
342 PathBuf::from("../external"),
343 )
344 .await
345 .unwrap();
346
347 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
348 cx.executor().run_until_parked();
349
350 let tool = Arc::new(CopyPathTool::new(project));
351
352 let input = CopyPathToolInput {
353 source_path: "project/link_to_external".into(),
354 destination_path: "project/external_copy".into(),
355 };
356
357 let (event_stream, mut event_rx) = ToolCallEventStream::test();
358 let task = cx.update(|cx| tool.run(input, event_stream, cx));
359
360 let auth = event_rx.expect_authorization().await;
361 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
362 assert!(
363 title.contains("points outside the project")
364 || title.contains("symlinks outside project"),
365 "Authorization title should mention symlink escape, got: {title}",
366 );
367
368 auth.response
369 .send(acp::PermissionOptionId::new("allow"))
370 .unwrap();
371
372 assert!(
373 !matches!(
374 event_rx.try_next(),
375 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
376 ),
377 "Expected a single authorization prompt",
378 );
379
380 let result = task.await;
381 assert!(
382 result.is_ok(),
383 "Tool should succeed after one authorization: {result:?}"
384 );
385 }
386
387 #[gpui::test]
388 async fn test_copy_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
389 init_test(cx);
390 cx.update(|cx| {
391 let mut settings = AgentSettings::get_global(cx).clone();
392 settings.tool_permissions.tools.insert(
393 "copy_path".into(),
394 agent_settings::ToolRules {
395 default: Some(settings::ToolPermissionMode::Deny),
396 ..Default::default()
397 },
398 );
399 AgentSettings::override_global(settings, cx);
400 });
401
402 let fs = FakeFs::new(cx.executor());
403 fs.insert_tree(
404 path!("/root"),
405 json!({
406 "project": {
407 "src": { "file.txt": "content" }
408 },
409 "external": {
410 "secret.txt": "SECRET"
411 }
412 }),
413 )
414 .await;
415
416 fs.create_symlink(
417 path!("/root/project/link_to_external").as_ref(),
418 PathBuf::from("../external"),
419 )
420 .await
421 .unwrap();
422
423 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
424 cx.executor().run_until_parked();
425
426 let tool = Arc::new(CopyPathTool::new(project));
427
428 let input = CopyPathToolInput {
429 source_path: "project/link_to_external".into(),
430 destination_path: "project/external_copy".into(),
431 };
432
433 let (event_stream, mut event_rx) = ToolCallEventStream::test();
434 let result = cx.update(|cx| tool.run(input, event_stream, cx)).await;
435
436 assert!(result.is_err(), "Tool should fail when policy denies");
437 assert!(
438 !matches!(
439 event_rx.try_next(),
440 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
441 ),
442 "Deny policy should not emit symlink authorization prompt",
443 );
444 }
445}