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