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::schema as acp;
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() -> acp::ToolKind {
59 acp::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 fs::Fs as _;
232 use gpui::TestAppContext;
233 use project::{FakeFs, Project};
234 use serde_json::json;
235 use settings::SettingsStore;
236 use std::path::PathBuf;
237 use util::path;
238
239 use crate::ToolCallEventStream;
240
241 fn init_test(cx: &mut TestAppContext) {
242 cx.update(|cx| {
243 let settings_store = SettingsStore::test(cx);
244 cx.set_global(settings_store);
245 });
246 cx.update(|cx| {
247 let mut settings = AgentSettings::get_global(cx).clone();
248 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
249 AgentSettings::override_global(settings, cx);
250 });
251 }
252
253 #[gpui::test]
254 async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
255 init_test(cx);
256
257 let fs = FakeFs::new(cx.executor());
258 fs.insert_tree(
259 path!("/root"),
260 json!({
261 "project": {
262 "src": { "main.rs": "fn main() {}" }
263 },
264 "external": {
265 "data": { "file.txt": "content" }
266 }
267 }),
268 )
269 .await;
270
271 fs.create_symlink(
272 path!("/root/project/link_to_external").as_ref(),
273 PathBuf::from("../external"),
274 )
275 .await
276 .unwrap();
277
278 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
279 cx.executor().run_until_parked();
280
281 let action_log = cx.new(|_| ActionLog::new(project.clone()));
282 let tool = Arc::new(DeletePathTool::new(project, action_log));
283
284 let (event_stream, mut event_rx) = ToolCallEventStream::test();
285 let task = cx.update(|cx| {
286 tool.run(
287 ToolInput::resolved(DeletePathToolInput {
288 path: "project/link_to_external".into(),
289 }),
290 event_stream,
291 cx,
292 )
293 });
294
295 let auth = event_rx.expect_authorization().await;
296 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
297 assert!(
298 title.contains("points outside the project") || title.contains("symlink"),
299 "Authorization title should mention symlink escape, got: {title}",
300 );
301
302 auth.response
303 .send(acp_thread::SelectedPermissionOutcome::new(
304 acp::PermissionOptionId::new("allow"),
305 acp::PermissionOptionKind::AllowOnce,
306 ))
307 .unwrap();
308
309 let result = task.await;
310 // FakeFs cannot delete symlink entries (they are neither Dir nor File
311 // internally), so the deletion itself may fail. The important thing is
312 // that the authorization was requested and accepted — any error must
313 // come from the fs layer, not from a permission denial.
314 if let Err(err) = &result {
315 let msg = format!("{err:#}");
316 assert!(
317 !msg.contains("denied") && !msg.contains("authorization"),
318 "Error should not be a permission denial, got: {msg}",
319 );
320 }
321 }
322
323 #[gpui::test]
324 async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
325 init_test(cx);
326
327 let fs = FakeFs::new(cx.executor());
328 fs.insert_tree(
329 path!("/root"),
330 json!({
331 "project": {
332 "src": { "main.rs": "fn main() {}" }
333 },
334 "external": {
335 "data": { "file.txt": "content" }
336 }
337 }),
338 )
339 .await;
340
341 fs.create_symlink(
342 path!("/root/project/link_to_external").as_ref(),
343 PathBuf::from("../external"),
344 )
345 .await
346 .unwrap();
347
348 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
349 cx.executor().run_until_parked();
350
351 let action_log = cx.new(|_| ActionLog::new(project.clone()));
352 let tool = Arc::new(DeletePathTool::new(project, action_log));
353
354 let (event_stream, mut event_rx) = ToolCallEventStream::test();
355 let task = cx.update(|cx| {
356 tool.run(
357 ToolInput::resolved(DeletePathToolInput {
358 path: "project/link_to_external".into(),
359 }),
360 event_stream,
361 cx,
362 )
363 });
364
365 let auth = event_rx.expect_authorization().await;
366
367 drop(auth);
368
369 let result = task.await;
370 assert!(
371 result.is_err(),
372 "Tool should fail when authorization is denied"
373 );
374 }
375
376 #[gpui::test]
377 async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
378 cx: &mut TestAppContext,
379 ) {
380 init_test(cx);
381 cx.update(|cx| {
382 let mut settings = AgentSettings::get_global(cx).clone();
383 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
384 AgentSettings::override_global(settings, cx);
385 });
386
387 let fs = FakeFs::new(cx.executor());
388 fs.insert_tree(
389 path!("/root"),
390 json!({
391 "project": {
392 "src": { "main.rs": "fn main() {}" }
393 },
394 "external": {
395 "data": { "file.txt": "content" }
396 }
397 }),
398 )
399 .await;
400
401 fs.create_symlink(
402 path!("/root/project/link_to_external").as_ref(),
403 PathBuf::from("../external"),
404 )
405 .await
406 .unwrap();
407
408 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
409 cx.executor().run_until_parked();
410
411 let action_log = cx.new(|_| ActionLog::new(project.clone()));
412 let tool = Arc::new(DeletePathTool::new(project, action_log));
413
414 let (event_stream, mut event_rx) = ToolCallEventStream::test();
415 let task = cx.update(|cx| {
416 tool.run(
417 ToolInput::resolved(DeletePathToolInput {
418 path: "project/link_to_external".into(),
419 }),
420 event_stream,
421 cx,
422 )
423 });
424
425 let auth = event_rx.expect_authorization().await;
426 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
427 assert!(
428 title.contains("points outside the project") || title.contains("symlink"),
429 "Authorization title should mention symlink escape, got: {title}",
430 );
431
432 auth.response
433 .send(acp_thread::SelectedPermissionOutcome::new(
434 acp::PermissionOptionId::new("allow"),
435 acp::PermissionOptionKind::AllowOnce,
436 ))
437 .unwrap();
438
439 assert!(
440 !matches!(
441 event_rx.try_recv(),
442 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
443 ),
444 "Expected a single authorization prompt",
445 );
446
447 let result = task.await;
448 if let Err(err) = &result {
449 let message = format!("{err:#}");
450 assert!(
451 !message.contains("denied") && !message.contains("authorization"),
452 "Error should not be a permission denial, got: {message}",
453 );
454 }
455 }
456
457 #[gpui::test]
458 async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
459 init_test(cx);
460 cx.update(|cx| {
461 let mut settings = AgentSettings::get_global(cx).clone();
462 settings.tool_permissions.tools.insert(
463 "delete_path".into(),
464 agent_settings::ToolRules {
465 default: Some(settings::ToolPermissionMode::Deny),
466 ..Default::default()
467 },
468 );
469 AgentSettings::override_global(settings, cx);
470 });
471
472 let fs = FakeFs::new(cx.executor());
473 fs.insert_tree(
474 path!("/root"),
475 json!({
476 "project": {
477 "src": { "main.rs": "fn main() {}" }
478 },
479 "external": {
480 "data": { "file.txt": "content" }
481 }
482 }),
483 )
484 .await;
485
486 fs.create_symlink(
487 path!("/root/project/link_to_external").as_ref(),
488 PathBuf::from("../external"),
489 )
490 .await
491 .unwrap();
492
493 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
494 cx.executor().run_until_parked();
495
496 let action_log = cx.new(|_| ActionLog::new(project.clone()));
497 let tool = Arc::new(DeletePathTool::new(project, action_log));
498
499 let (event_stream, mut event_rx) = ToolCallEventStream::test();
500 let result = cx
501 .update(|cx| {
502 tool.run(
503 ToolInput::resolved(DeletePathToolInput {
504 path: "project/link_to_external".into(),
505 }),
506 event_stream,
507 cx,
508 )
509 })
510 .await;
511
512 assert!(result.is_err(), "Tool should fail when policy denies");
513 assert!(
514 !matches!(
515 event_rx.try_recv(),
516 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
517 ),
518 "Deny policy should not emit symlink authorization prompt",
519 );
520 }
521}