1use super::tool_permissions::{
2 SensitiveSettingsKind, authorize_symlink_access, canonicalize_worktree_roots,
3 detect_symlink_escape, sensitive_settings_kind,
4};
5use crate::{
6 AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, decide_permission_for_path,
7};
8use action_log::ActionLog;
9use agent_client_protocol::ToolKind;
10use agent_settings::AgentSettings;
11use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc};
12use gpui::{App, AppContext, Entity, SharedString, Task};
13use project::{Project, ProjectPath};
14use schemars::JsonSchema;
15use serde::{Deserialize, Serialize};
16use settings::Settings;
17use std::path::Path;
18use std::sync::Arc;
19use util::markdown::MarkdownInlineCode;
20
21/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion.
22#[derive(Debug, Serialize, Deserialize, JsonSchema)]
23pub struct DeletePathToolInput {
24 /// The path of the file or directory to delete.
25 ///
26 /// <example>
27 /// If the project has the following files:
28 ///
29 /// - directory1/a/something.txt
30 /// - directory2/a/things.txt
31 /// - directory3/a/other.txt
32 ///
33 /// You can delete the first file by providing a path of "directory1/a/something.txt"
34 /// </example>
35 pub path: String,
36}
37
38pub struct DeletePathTool {
39 project: Entity<Project>,
40 action_log: Entity<ActionLog>,
41}
42
43impl DeletePathTool {
44 pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
45 Self {
46 project,
47 action_log,
48 }
49 }
50}
51
52impl AgentTool for DeletePathTool {
53 type Input = DeletePathToolInput;
54 type Output = String;
55
56 const NAME: &'static str = "delete_path";
57
58 fn kind() -> ToolKind {
59 ToolKind::Delete
60 }
61
62 fn initial_title(
63 &self,
64 input: Result<Self::Input, serde_json::Value>,
65 _cx: &mut App,
66 ) -> SharedString {
67 if let Ok(input) = input {
68 format!("Delete “`{}`”", input.path).into()
69 } else {
70 "Delete path".into()
71 }
72 }
73
74 fn run(
75 self: Arc<Self>,
76 input: ToolInput<Self::Input>,
77 event_stream: ToolCallEventStream,
78 cx: &mut App,
79 ) -> Task<Result<Self::Output, Self::Output>> {
80 let project = self.project.clone();
81 let action_log = self.action_log.clone();
82 cx.spawn(async move |cx| {
83 let input = input
84 .recv()
85 .await
86 .map_err(|e| format!("Failed to receive tool input: {e}"))?;
87 let path = input.path;
88
89 let decision = cx.update(|cx| {
90 decide_permission_for_path(Self::NAME, &path, AgentSettings::get_global(cx))
91 });
92
93 if let ToolPermissionDecision::Deny(reason) = decision {
94 return Err(reason);
95 }
96
97 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
98 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
99
100 let symlink_escape_target = project.read_with(cx, |project, cx| {
101 detect_symlink_escape(project, &path, &canonical_roots, cx)
102 .map(|(_, target)| target)
103 });
104
105 let settings_kind = sensitive_settings_kind(Path::new(&path), fs.as_ref()).await;
106
107 let decision =
108 if matches!(decision, ToolPermissionDecision::Allow) && settings_kind.is_some() {
109 ToolPermissionDecision::Confirm
110 } else {
111 decision
112 };
113
114 let authorize = if let Some(canonical_target) = symlink_escape_target {
115 // Symlink escape authorization replaces (rather than supplements)
116 // the normal tool-permission prompt. The symlink prompt already
117 // requires explicit user approval with the canonical target shown,
118 // which is strictly more security-relevant than a generic confirm.
119 Some(cx.update(|cx| {
120 authorize_symlink_access(
121 Self::NAME,
122 &path,
123 &canonical_target,
124 &event_stream,
125 cx,
126 )
127 }))
128 } else {
129 match decision {
130 ToolPermissionDecision::Allow => None,
131 ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
132 let context =
133 crate::ToolPermissionContext::new(Self::NAME, vec![path.clone()]);
134 let title = format!("Delete {}", MarkdownInlineCode(&path));
135 let title = match settings_kind {
136 Some(SensitiveSettingsKind::Local) => {
137 format!("{title} (local settings)")
138 }
139 Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
140 None => title,
141 };
142 event_stream.authorize(title, context, cx)
143 })),
144 ToolPermissionDecision::Deny(_) => None,
145 }
146 };
147
148 if let Some(authorize) = authorize {
149 authorize.await.map_err(|e| e.to_string())?;
150 }
151
152 let (project_path, worktree_snapshot) = project.read_with(cx, |project, cx| {
153 let project_path = project.find_project_path(&path, cx).ok_or_else(|| {
154 format!("Couldn't delete {path} because that path isn't in this project.")
155 })?;
156 let worktree = project
157 .worktree_for_id(project_path.worktree_id, cx)
158 .ok_or_else(|| {
159 format!("Couldn't delete {path} because that path isn't in this project.")
160 })?;
161 let worktree_snapshot = worktree.read(cx).snapshot();
162 Result::<_, String>::Ok((project_path, worktree_snapshot))
163 })?;
164
165 let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
166 cx.background_spawn({
167 let project_path = project_path.clone();
168 async move {
169 for entry in
170 worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
171 {
172 if !entry.path.starts_with(&project_path.path) {
173 break;
174 }
175 paths_tx
176 .send(ProjectPath {
177 worktree_id: project_path.worktree_id,
178 path: entry.path.clone(),
179 })
180 .await?;
181 }
182 anyhow::Ok(())
183 }
184 })
185 .detach();
186
187 loop {
188 let path_result = futures::select! {
189 path = paths_rx.next().fuse() => path,
190 _ = event_stream.cancelled_by_user().fuse() => {
191 return Err("Delete cancelled by user".to_string());
192 }
193 };
194 let Some(path) = path_result else {
195 break;
196 };
197 if let Ok(buffer) = project
198 .update(cx, |project, cx| project.open_buffer(path, cx))
199 .await
200 {
201 action_log.update(cx, |action_log, cx| {
202 action_log.will_delete_buffer(buffer.clone(), cx)
203 });
204 }
205 }
206
207 let deletion_task = project
208 .update(cx, |project, cx| {
209 project.delete_file(project_path, false, cx)
210 })
211 .ok_or_else(|| {
212 format!("Couldn't delete {path} because that path isn't in this project.")
213 })?;
214
215 futures::select! {
216 result = deletion_task.fuse() => {
217 result.map_err(|e| format!("Deleting {path}: {e}"))?;
218 }
219 _ = event_stream.cancelled_by_user().fuse() => {
220 return Err("Delete cancelled by user".to_string());
221 }
222 }
223 Ok(format!("Deleted {path}"))
224 })
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use agent_client_protocol as acp;
232 use fs::Fs as _;
233 use gpui::TestAppContext;
234 use project::{FakeFs, Project};
235 use serde_json::json;
236 use settings::SettingsStore;
237 use std::path::PathBuf;
238 use util::path;
239
240 use crate::ToolCallEventStream;
241
242 fn init_test(cx: &mut TestAppContext) {
243 cx.update(|cx| {
244 let settings_store = SettingsStore::test(cx);
245 cx.set_global(settings_store);
246 });
247 cx.update(|cx| {
248 let mut settings = AgentSettings::get_global(cx).clone();
249 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
250 AgentSettings::override_global(settings, cx);
251 });
252 }
253
254 #[gpui::test]
255 async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
256 init_test(cx);
257
258 let fs = FakeFs::new(cx.executor());
259 fs.insert_tree(
260 path!("/root"),
261 json!({
262 "project": {
263 "src": { "main.rs": "fn main() {}" }
264 },
265 "external": {
266 "data": { "file.txt": "content" }
267 }
268 }),
269 )
270 .await;
271
272 fs.create_symlink(
273 path!("/root/project/link_to_external").as_ref(),
274 PathBuf::from("../external"),
275 )
276 .await
277 .unwrap();
278
279 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
280 cx.executor().run_until_parked();
281
282 let action_log = cx.new(|_| ActionLog::new(project.clone()));
283 let tool = Arc::new(DeletePathTool::new(project, action_log));
284
285 let (event_stream, mut event_rx) = ToolCallEventStream::test();
286 let task = cx.update(|cx| {
287 tool.run(
288 ToolInput::resolved(DeletePathToolInput {
289 path: "project/link_to_external".into(),
290 }),
291 event_stream,
292 cx,
293 )
294 });
295
296 let auth = event_rx.expect_authorization().await;
297 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
298 assert!(
299 title.contains("points outside the project") || title.contains("symlink"),
300 "Authorization title should mention symlink escape, got: {title}",
301 );
302
303 auth.response
304 .send(acp_thread::SelectedPermissionOutcome::new(
305 acp::PermissionOptionId::new("allow"),
306 acp::PermissionOptionKind::AllowOnce,
307 ))
308 .unwrap();
309
310 let result = task.await;
311 // FakeFs cannot delete symlink entries (they are neither Dir nor File
312 // internally), so the deletion itself may fail. The important thing is
313 // that the authorization was requested and accepted — any error must
314 // come from the fs layer, not from a permission denial.
315 if let Err(err) = &result {
316 let msg = format!("{err:#}");
317 assert!(
318 !msg.contains("denied") && !msg.contains("authorization"),
319 "Error should not be a permission denial, got: {msg}",
320 );
321 }
322 }
323
324 #[gpui::test]
325 async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
326 init_test(cx);
327
328 let fs = FakeFs::new(cx.executor());
329 fs.insert_tree(
330 path!("/root"),
331 json!({
332 "project": {
333 "src": { "main.rs": "fn main() {}" }
334 },
335 "external": {
336 "data": { "file.txt": "content" }
337 }
338 }),
339 )
340 .await;
341
342 fs.create_symlink(
343 path!("/root/project/link_to_external").as_ref(),
344 PathBuf::from("../external"),
345 )
346 .await
347 .unwrap();
348
349 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
350 cx.executor().run_until_parked();
351
352 let action_log = cx.new(|_| ActionLog::new(project.clone()));
353 let tool = Arc::new(DeletePathTool::new(project, action_log));
354
355 let (event_stream, mut event_rx) = ToolCallEventStream::test();
356 let task = cx.update(|cx| {
357 tool.run(
358 ToolInput::resolved(DeletePathToolInput {
359 path: "project/link_to_external".into(),
360 }),
361 event_stream,
362 cx,
363 )
364 });
365
366 let auth = event_rx.expect_authorization().await;
367
368 drop(auth);
369
370 let result = task.await;
371 assert!(
372 result.is_err(),
373 "Tool should fail when authorization is denied"
374 );
375 }
376
377 #[gpui::test]
378 async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
379 cx: &mut TestAppContext,
380 ) {
381 init_test(cx);
382 cx.update(|cx| {
383 let mut settings = AgentSettings::get_global(cx).clone();
384 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
385 AgentSettings::override_global(settings, cx);
386 });
387
388 let fs = FakeFs::new(cx.executor());
389 fs.insert_tree(
390 path!("/root"),
391 json!({
392 "project": {
393 "src": { "main.rs": "fn main() {}" }
394 },
395 "external": {
396 "data": { "file.txt": "content" }
397 }
398 }),
399 )
400 .await;
401
402 fs.create_symlink(
403 path!("/root/project/link_to_external").as_ref(),
404 PathBuf::from("../external"),
405 )
406 .await
407 .unwrap();
408
409 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
410 cx.executor().run_until_parked();
411
412 let action_log = cx.new(|_| ActionLog::new(project.clone()));
413 let tool = Arc::new(DeletePathTool::new(project, action_log));
414
415 let (event_stream, mut event_rx) = ToolCallEventStream::test();
416 let task = cx.update(|cx| {
417 tool.run(
418 ToolInput::resolved(DeletePathToolInput {
419 path: "project/link_to_external".into(),
420 }),
421 event_stream,
422 cx,
423 )
424 });
425
426 let auth = event_rx.expect_authorization().await;
427 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
428 assert!(
429 title.contains("points outside the project") || title.contains("symlink"),
430 "Authorization title should mention symlink escape, got: {title}",
431 );
432
433 auth.response
434 .send(acp_thread::SelectedPermissionOutcome::new(
435 acp::PermissionOptionId::new("allow"),
436 acp::PermissionOptionKind::AllowOnce,
437 ))
438 .unwrap();
439
440 assert!(
441 !matches!(
442 event_rx.try_recv(),
443 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
444 ),
445 "Expected a single authorization prompt",
446 );
447
448 let result = task.await;
449 if let Err(err) = &result {
450 let message = format!("{err:#}");
451 assert!(
452 !message.contains("denied") && !message.contains("authorization"),
453 "Error should not be a permission denial, got: {message}",
454 );
455 }
456 }
457
458 #[gpui::test]
459 async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
460 init_test(cx);
461 cx.update(|cx| {
462 let mut settings = AgentSettings::get_global(cx).clone();
463 settings.tool_permissions.tools.insert(
464 "delete_path".into(),
465 agent_settings::ToolRules {
466 default: Some(settings::ToolPermissionMode::Deny),
467 ..Default::default()
468 },
469 );
470 AgentSettings::override_global(settings, cx);
471 });
472
473 let fs = FakeFs::new(cx.executor());
474 fs.insert_tree(
475 path!("/root"),
476 json!({
477 "project": {
478 "src": { "main.rs": "fn main() {}" }
479 },
480 "external": {
481 "data": { "file.txt": "content" }
482 }
483 }),
484 )
485 .await;
486
487 fs.create_symlink(
488 path!("/root/project/link_to_external").as_ref(),
489 PathBuf::from("../external"),
490 )
491 .await
492 .unwrap();
493
494 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
495 cx.executor().run_until_parked();
496
497 let action_log = cx.new(|_| ActionLog::new(project.clone()));
498 let tool = Arc::new(DeletePathTool::new(project, action_log));
499
500 let (event_stream, mut event_rx) = ToolCallEventStream::test();
501 let result = cx
502 .update(|cx| {
503 tool.run(
504 ToolInput::resolved(DeletePathToolInput {
505 path: "project/link_to_external".into(),
506 }),
507 event_stream,
508 cx,
509 )
510 })
511 .await;
512
513 assert!(result.is_err(), "Tool should fail when policy denies");
514 assert!(
515 !matches!(
516 event_rx.try_recv(),
517 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
518 ),
519 "Deny policy should not emit symlink authorization prompt",
520 );
521 }
522}