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