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::PermissionOptionId::new("allow"))
305 .unwrap();
306
307 let result = task.await;
308 // FakeFs cannot delete symlink entries (they are neither Dir nor File
309 // internally), so the deletion itself may fail. The important thing is
310 // that the authorization was requested and accepted — any error must
311 // come from the fs layer, not from a permission denial.
312 if let Err(err) = &result {
313 let msg = format!("{err:#}");
314 assert!(
315 !msg.contains("denied") && !msg.contains("authorization"),
316 "Error should not be a permission denial, got: {msg}",
317 );
318 }
319 }
320
321 #[gpui::test]
322 async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
323 init_test(cx);
324
325 let fs = FakeFs::new(cx.executor());
326 fs.insert_tree(
327 path!("/root"),
328 json!({
329 "project": {
330 "src": { "main.rs": "fn main() {}" }
331 },
332 "external": {
333 "data": { "file.txt": "content" }
334 }
335 }),
336 )
337 .await;
338
339 fs.create_symlink(
340 path!("/root/project/link_to_external").as_ref(),
341 PathBuf::from("../external"),
342 )
343 .await
344 .unwrap();
345
346 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
347 cx.executor().run_until_parked();
348
349 let action_log = cx.new(|_| ActionLog::new(project.clone()));
350 let tool = Arc::new(DeletePathTool::new(project, action_log));
351
352 let (event_stream, mut event_rx) = ToolCallEventStream::test();
353 let task = cx.update(|cx| {
354 tool.run(
355 ToolInput::resolved(DeletePathToolInput {
356 path: "project/link_to_external".into(),
357 }),
358 event_stream,
359 cx,
360 )
361 });
362
363 let auth = event_rx.expect_authorization().await;
364
365 drop(auth);
366
367 let result = task.await;
368 assert!(
369 result.is_err(),
370 "Tool should fail when authorization is denied"
371 );
372 }
373
374 #[gpui::test]
375 async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
376 cx: &mut TestAppContext,
377 ) {
378 init_test(cx);
379 cx.update(|cx| {
380 let mut settings = AgentSettings::get_global(cx).clone();
381 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
382 AgentSettings::override_global(settings, cx);
383 });
384
385 let fs = FakeFs::new(cx.executor());
386 fs.insert_tree(
387 path!("/root"),
388 json!({
389 "project": {
390 "src": { "main.rs": "fn main() {}" }
391 },
392 "external": {
393 "data": { "file.txt": "content" }
394 }
395 }),
396 )
397 .await;
398
399 fs.create_symlink(
400 path!("/root/project/link_to_external").as_ref(),
401 PathBuf::from("../external"),
402 )
403 .await
404 .unwrap();
405
406 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
407 cx.executor().run_until_parked();
408
409 let action_log = cx.new(|_| ActionLog::new(project.clone()));
410 let tool = Arc::new(DeletePathTool::new(project, action_log));
411
412 let (event_stream, mut event_rx) = ToolCallEventStream::test();
413 let task = cx.update(|cx| {
414 tool.run(
415 ToolInput::resolved(DeletePathToolInput {
416 path: "project/link_to_external".into(),
417 }),
418 event_stream,
419 cx,
420 )
421 });
422
423 let auth = event_rx.expect_authorization().await;
424 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
425 assert!(
426 title.contains("points outside the project") || title.contains("symlink"),
427 "Authorization title should mention symlink escape, got: {title}",
428 );
429
430 auth.response
431 .send(acp::PermissionOptionId::new("allow"))
432 .unwrap();
433
434 assert!(
435 !matches!(
436 event_rx.try_next(),
437 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
438 ),
439 "Expected a single authorization prompt",
440 );
441
442 let result = task.await;
443 if let Err(err) = &result {
444 let message = format!("{err:#}");
445 assert!(
446 !message.contains("denied") && !message.contains("authorization"),
447 "Error should not be a permission denial, got: {message}",
448 );
449 }
450 }
451
452 #[gpui::test]
453 async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
454 init_test(cx);
455 cx.update(|cx| {
456 let mut settings = AgentSettings::get_global(cx).clone();
457 settings.tool_permissions.tools.insert(
458 "delete_path".into(),
459 agent_settings::ToolRules {
460 default: Some(settings::ToolPermissionMode::Deny),
461 ..Default::default()
462 },
463 );
464 AgentSettings::override_global(settings, cx);
465 });
466
467 let fs = FakeFs::new(cx.executor());
468 fs.insert_tree(
469 path!("/root"),
470 json!({
471 "project": {
472 "src": { "main.rs": "fn main() {}" }
473 },
474 "external": {
475 "data": { "file.txt": "content" }
476 }
477 }),
478 )
479 .await;
480
481 fs.create_symlink(
482 path!("/root/project/link_to_external").as_ref(),
483 PathBuf::from("../external"),
484 )
485 .await
486 .unwrap();
487
488 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
489 cx.executor().run_until_parked();
490
491 let action_log = cx.new(|_| ActionLog::new(project.clone()));
492 let tool = Arc::new(DeletePathTool::new(project, action_log));
493
494 let (event_stream, mut event_rx) = ToolCallEventStream::test();
495 let result = cx
496 .update(|cx| {
497 tool.run(
498 ToolInput::resolved(DeletePathToolInput {
499 path: "project/link_to_external".into(),
500 }),
501 event_stream,
502 cx,
503 )
504 })
505 .await;
506
507 assert!(result.is_err(), "Tool should fail when policy denies");
508 assert!(
509 !matches!(
510 event_rx.try_next(),
511 Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
512 ),
513 "Deny policy should not emit symlink authorization prompt",
514 );
515 }
516}