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