Skip to repository content708 lines · 25.4 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T03:30:31.830Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
delete_path_tool.rs
1use super::tool_permissions::{
2 authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape,
3 resolve_global_skill_descendant_path, resolves_to_global_skills_dir, sensitive_settings_kind,
4};
5use crate::{
6 AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
7 authorize_with_sensitive_settings, decide_permission_for_path,
8};
9use action_log::ActionLog;
10use agent_client_protocol::schema::v1 as acp;
11use agent_settings::AgentSettings;
12use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc};
13use gpui::{App, AppContext, Entity, SharedString, Task};
14use project::{Project, ProjectPath};
15use schemars::JsonSchema;
16use serde::{Deserialize, Serialize};
17use settings::Settings;
18use std::path::Path;
19use std::sync::Arc;
20use util::markdown::MarkdownInlineCode;
21
22/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion.
23///
24/// The only supported paths outside the project are descendants of `~/.agents/skills`, for global agent skills.
25#[derive(Debug, Serialize, Deserialize, JsonSchema)]
26pub struct DeletePathToolInput {
27 /// The path of the file or directory to delete.
28 ///
29 /// <example>
30 /// If the project has the following files:
31 ///
32 /// - directory1/a/something.txt
33 /// - directory2/a/things.txt
34 /// - directory3/a/other.txt
35 ///
36 /// You can delete the first file by providing a path of "directory1/a/something.txt"
37 /// </example>
38 pub path: String,
39}
40
41pub struct DeletePathTool {
42 project: Entity<Project>,
43 action_log: Entity<ActionLog>,
44}
45
46impl DeletePathTool {
47 pub fn new(project: Entity<Project>, action_log: Entity<ActionLog>) -> Self {
48 Self {
49 project,
50 action_log,
51 }
52 }
53}
54
55impl AgentTool for DeletePathTool {
56 type Input = DeletePathToolInput;
57 type Output = String;
58
59 const NAME: &'static str = "delete_path";
60
61 fn kind() -> acp::ToolKind {
62 acp::ToolKind::Delete
63 }
64
65 fn initial_title(
66 &self,
67 input: Result<Self::Input, serde_json::Value>,
68 _cx: &mut App,
69 ) -> SharedString {
70 if let Ok(input) = input {
71 format!("Delete “`{}`”", input.path).into()
72 } else {
73 "Delete path".into()
74 }
75 }
76
77 fn run(
78 self: Arc<Self>,
79 input: ToolInput<Self::Input>,
80 event_stream: ToolCallEventStream,
81 cx: &mut App,
82 ) -> Task<Result<Self::Output, Self::Output>> {
83 let project = self.project.clone();
84 let action_log = self.action_log.clone();
85 cx.spawn(async move |cx| {
86 let input = input.recv().await.map_err(|e| e.to_string())?;
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 if resolves_to_global_skills_dir(Path::new(&path), fs.as_ref()).await {
101 return Err(
102 "Cannot delete the global agent skills directory itself. Delete a skill directory or file beneath it instead."
103 .to_string(),
104 );
105 }
106
107 let global_skill_path =
108 resolve_global_skill_descendant_path(Path::new(&path), fs.as_ref()).await;
109
110 let symlink_escape_target = project.read_with(cx, |project, cx| {
111 detect_symlink_escape(project, &path, &canonical_roots, cx)
112 .map(|(_, target)| target)
113 });
114
115 let settings_kind =
116 sensitive_settings_kind(Path::new(&path), &canonical_roots, fs.as_ref()).await;
117
118 let decision =
119 if matches!(decision, ToolPermissionDecision::Allow) && settings_kind.is_some() {
120 ToolPermissionDecision::Confirm
121 } else {
122 decision
123 };
124
125 let authorize = if let Some(canonical_target) = symlink_escape_target {
126 // Symlink escape authorization replaces (rather than supplements)
127 // the normal tool-permission prompt. The symlink prompt already
128 // requires explicit user approval with the canonical target shown,
129 // which is strictly more security-relevant than a generic confirm.
130 Some(cx.update(|cx| {
131 authorize_symlink_access(
132 Self::NAME,
133 &path,
134 &canonical_target,
135 &event_stream,
136 cx,
137 )
138 }))
139 } else {
140 match decision {
141 ToolPermissionDecision::Allow => None,
142 ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
143 let context =
144 crate::ToolPermissionContext::new(Self::NAME, vec![path.clone()]);
145 let title = format!("Delete {}", MarkdownInlineCode(&path));
146 authorize_with_sensitive_settings(
147 settings_kind,
148 context,
149 &title,
150 &event_stream,
151 cx,
152 )
153 })),
154 ToolPermissionDecision::Deny(_) => None,
155 }
156 };
157
158 if let Some(authorize) = authorize {
159 authorize.await.map_err(|e| e.to_string())?;
160 }
161
162 if let Some(global_skill_path) = global_skill_path {
163 let metadata = fs
164 .metadata(&global_skill_path)
165 .await
166 .map_err(|e| format!("Deleting {path}: {e}"))?
167 .ok_or_else(|| format!("Deleting {path}: path not found"))?;
168
169 futures::select! {
170 result = async {
171 if metadata.is_dir {
172 fs.remove_dir(
173 &global_skill_path,
174 fs::RemoveOptions {
175 recursive: true,
176 ..fs::RemoveOptions::default()
177 },
178 )
179 .await
180 } else {
181 fs.remove_file(&global_skill_path, fs::RemoveOptions::default()).await
182 }
183 }.fuse() => {
184 result.map_err(|e| format!("Deleting {path}: {e}"))?;
185 }
186 _ = event_stream.cancelled_by_user().fuse() => {
187 return Err("Delete cancelled by user".to_string());
188 }
189 }
190
191 return Ok(format!("Deleted {path}"));
192 }
193
194 let (project_path, worktree_snapshot) = project.read_with(cx, |project, cx| {
195 let project_path = project.find_project_path(&path, cx).ok_or_else(|| {
196 format!("Couldn't delete {path} because that path isn't in this project.")
197 })?;
198 let worktree = project
199 .worktree_for_id(project_path.worktree_id, cx)
200 .ok_or_else(|| {
201 format!("Couldn't delete {path} because that path isn't in this project.")
202 })?;
203 let worktree_snapshot = worktree.read(cx).snapshot();
204 Result::<_, String>::Ok((project_path, worktree_snapshot))
205 })?;
206
207 let (mut paths_tx, mut paths_rx) = mpsc::channel(256);
208 cx.background_spawn({
209 let project_path = project_path.clone();
210 async move {
211 for entry in
212 worktree_snapshot.traverse_from_path(true, false, false, &project_path.path)
213 {
214 if !entry.path.starts_with(&project_path.path) {
215 break;
216 }
217 paths_tx
218 .send(ProjectPath {
219 worktree_id: project_path.worktree_id,
220 path: entry.path.clone(),
221 })
222 .await?;
223 }
224 anyhow::Ok(())
225 }
226 })
227 .detach();
228
229 loop {
230 let path_result = futures::select! {
231 path = paths_rx.next().fuse() => path,
232 _ = event_stream.cancelled_by_user().fuse() => {
233 return Err("Delete cancelled by user".to_string());
234 }
235 };
236 let Some(path) = path_result else {
237 break;
238 };
239 if let Ok(buffer) = project
240 .update(cx, |project, cx| project.open_buffer(path, cx))
241 .await
242 {
243 action_log.update(cx, |action_log, cx| {
244 action_log.will_delete_buffer(buffer.clone(), cx)
245 });
246 }
247 }
248
249 let deletion_task = project
250 .update(cx, |project, cx| project.delete_file(project_path, cx))
251 .ok_or_else(|| {
252 format!("Couldn't delete {path} because that path isn't in this project.")
253 })?;
254
255 futures::select! {
256 result = deletion_task.fuse() => {
257 result.map_err(|e| format!("Deleting {path}: {e}"))?;
258 }
259 _ = event_stream.cancelled_by_user().fuse() => {
260 return Err("Delete cancelled by user".to_string());
261 }
262 }
263 Ok(format!("Deleted {path}"))
264 })
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use fs::Fs as _;
272 use gpui::TestAppContext;
273 use project::{FakeFs, Project};
274 use serde_json::json;
275 use settings::SettingsStore;
276 use std::path::PathBuf;
277 use util::path;
278
279 use crate::ToolCallEventStream;
280
281 fn init_test(cx: &mut TestAppContext) {
282 cx.update(|cx| {
283 let settings_store = SettingsStore::test(cx);
284 cx.set_global(settings_store);
285 });
286 cx.update(|cx| {
287 let mut settings = AgentSettings::get_global(cx).clone();
288 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
289 AgentSettings::override_global(settings, cx);
290 });
291 }
292
293 #[gpui::test]
294 async fn test_delete_path_global_skill_directory(cx: &mut TestAppContext) {
295 init_test(cx);
296
297 let fs = FakeFs::new(cx.executor());
298 fs.insert_tree(path!("/root/project"), json!({})).await;
299 let skills_dir = agent_skills::global_skills_dir();
300 let skill_dir = skills_dir.join("my-skill");
301 fs.insert_tree(&skill_dir, json!({ "SKILL.md": "content" }))
302 .await;
303 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
304 cx.executor().run_until_parked();
305
306 let action_log = cx.new(|_| ActionLog::new(project.clone()));
307 let tool = Arc::new(DeletePathTool::new(project, action_log));
308 let input_path = PathBuf::from("~")
309 .join(".agents")
310 .join("skills")
311 .join("my-skill")
312 .to_string_lossy()
313 .into_owned();
314
315 let (event_stream, mut event_rx) = ToolCallEventStream::test();
316 let task = cx.update(|cx| {
317 tool.run(
318 ToolInput::resolved(DeletePathToolInput { path: input_path }),
319 event_stream,
320 cx,
321 )
322 });
323
324 let auth = event_rx.expect_authorization().await;
325 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
326 assert!(
327 title.contains("agent skills"),
328 "Authorization title should mention agent skills, got: {title}",
329 );
330 assert!(
331 auth.options
332 .first_option_of_kind(acp::PermissionOptionKind::AllowAlways)
333 .is_none(),
334 "agent skills prompt must not offer an \"Always allow\" option: {:?}",
335 auth.options,
336 );
337 auth.response
338 .send(acp_thread::SelectedPermissionOutcome::new(
339 acp::PermissionOptionId::new("allow"),
340 acp::PermissionOptionKind::AllowOnce,
341 ))
342 .expect("authorization response should send");
343
344 let result = task.await;
345 assert!(result.is_ok(), "should delete after approval: {result:?}");
346 assert!(fs.is_dir(&skills_dir).await);
347 assert!(!fs.is_dir(&skill_dir).await);
348 }
349
350 #[gpui::test]
351 async fn test_delete_path_global_skill_file(cx: &mut TestAppContext) {
352 init_test(cx);
353
354 let fs = FakeFs::new(cx.executor());
355 fs.insert_tree(path!("/root/project"), json!({})).await;
356 let skill_file = agent_skills::global_skills_dir()
357 .join("my-skill")
358 .join("references")
359 .join("notes.md");
360 fs.create_dir(skill_file.parent().unwrap()).await.unwrap();
361 fs.insert_file(&skill_file, b"notes".to_vec()).await;
362 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
363 cx.executor().run_until_parked();
364
365 let action_log = cx.new(|_| ActionLog::new(project.clone()));
366 let tool = Arc::new(DeletePathTool::new(project, action_log));
367 let input_path = PathBuf::from("~")
368 .join(".agents")
369 .join("skills")
370 .join("my-skill")
371 .join("references")
372 .join("notes.md")
373 .to_string_lossy()
374 .into_owned();
375
376 let (event_stream, mut event_rx) = ToolCallEventStream::test();
377 let task = cx.update(|cx| {
378 tool.run(
379 ToolInput::resolved(DeletePathToolInput { path: input_path }),
380 event_stream,
381 cx,
382 )
383 });
384
385 let auth = event_rx.expect_authorization().await;
386 auth.response
387 .send(acp_thread::SelectedPermissionOutcome::new(
388 acp::PermissionOptionId::new("allow"),
389 acp::PermissionOptionKind::AllowOnce,
390 ))
391 .expect("authorization response should send");
392
393 let result = task.await;
394 assert!(result.is_ok(), "should delete after approval: {result:?}");
395 assert!(!fs.is_file(&skill_file).await);
396 }
397
398 #[gpui::test]
399 async fn test_delete_path_rejects_global_skills_root(cx: &mut TestAppContext) {
400 init_test(cx);
401
402 let fs = FakeFs::new(cx.executor());
403 fs.insert_tree(path!("/root/project"), json!({})).await;
404 let skills_dir = agent_skills::global_skills_dir();
405 fs.create_dir(&skills_dir).await.unwrap();
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 let input_path = PathBuf::from("~")
412 .join(".agents")
413 .join("skills")
414 .to_string_lossy()
415 .into_owned();
416
417 let (event_stream, mut event_rx) = ToolCallEventStream::test();
418 let result = cx
419 .update(|cx| {
420 tool.run(
421 ToolInput::resolved(DeletePathToolInput { path: input_path }),
422 event_stream,
423 cx,
424 )
425 })
426 .await;
427
428 assert!(result.is_err(), "should reject deleting skills root");
429 assert!(fs.is_dir(&skills_dir).await);
430 assert!(
431 !matches!(
432 event_rx.try_recv(),
433 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
434 ),
435 "Deleting the skills root should fail before requesting authorization",
436 );
437 }
438
439 #[gpui::test]
440 async fn test_delete_path_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
441 init_test(cx);
442
443 let fs = FakeFs::new(cx.executor());
444 fs.insert_tree(
445 path!("/root"),
446 json!({
447 "project": {
448 "src": { "main.rs": "fn main() {}" }
449 },
450 "external": {
451 "data": { "file.txt": "content" }
452 }
453 }),
454 )
455 .await;
456
457 fs.create_symlink(
458 path!("/root/project/link_to_external").as_ref(),
459 PathBuf::from("../external"),
460 )
461 .await
462 .unwrap();
463
464 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
465 cx.executor().run_until_parked();
466
467 let action_log = cx.new(|_| ActionLog::new(project.clone()));
468 let tool = Arc::new(DeletePathTool::new(project, action_log));
469
470 let (event_stream, mut event_rx) = ToolCallEventStream::test();
471 let task = cx.update(|cx| {
472 tool.run(
473 ToolInput::resolved(DeletePathToolInput {
474 path: "project/link_to_external".into(),
475 }),
476 event_stream,
477 cx,
478 )
479 });
480
481 let auth = event_rx.expect_authorization().await;
482 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
483 assert!(
484 title.contains("points outside the project") || title.contains("symlink"),
485 "Authorization title should mention symlink escape, got: {title}",
486 );
487
488 auth.response
489 .send(acp_thread::SelectedPermissionOutcome::new(
490 acp::PermissionOptionId::new("allow"),
491 acp::PermissionOptionKind::AllowOnce,
492 ))
493 .unwrap();
494
495 let result = task.await;
496 // FakeFs cannot delete symlink entries (they are neither Dir nor File
497 // internally), so the deletion itself may fail. The important thing is
498 // that the authorization was requested and accepted — any error must
499 // come from the fs layer, not from a permission denial.
500 if let Err(err) = &result {
501 let msg = format!("{err:#}");
502 assert!(
503 !msg.contains("denied") && !msg.contains("authorization"),
504 "Error should not be a permission denial, got: {msg}",
505 );
506 }
507 }
508
509 #[gpui::test]
510 async fn test_delete_path_symlink_escape_denied(cx: &mut TestAppContext) {
511 init_test(cx);
512
513 let fs = FakeFs::new(cx.executor());
514 fs.insert_tree(
515 path!("/root"),
516 json!({
517 "project": {
518 "src": { "main.rs": "fn main() {}" }
519 },
520 "external": {
521 "data": { "file.txt": "content" }
522 }
523 }),
524 )
525 .await;
526
527 fs.create_symlink(
528 path!("/root/project/link_to_external").as_ref(),
529 PathBuf::from("../external"),
530 )
531 .await
532 .unwrap();
533
534 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
535 cx.executor().run_until_parked();
536
537 let action_log = cx.new(|_| ActionLog::new(project.clone()));
538 let tool = Arc::new(DeletePathTool::new(project, action_log));
539
540 let (event_stream, mut event_rx) = ToolCallEventStream::test();
541 let task = cx.update(|cx| {
542 tool.run(
543 ToolInput::resolved(DeletePathToolInput {
544 path: "project/link_to_external".into(),
545 }),
546 event_stream,
547 cx,
548 )
549 });
550
551 let auth = event_rx.expect_authorization().await;
552
553 drop(auth);
554
555 let result = task.await;
556 assert!(
557 result.is_err(),
558 "Tool should fail when authorization is denied"
559 );
560 }
561
562 #[gpui::test]
563 async fn test_delete_path_symlink_escape_confirm_requires_single_approval(
564 cx: &mut TestAppContext,
565 ) {
566 init_test(cx);
567 cx.update(|cx| {
568 let mut settings = AgentSettings::get_global(cx).clone();
569 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
570 AgentSettings::override_global(settings, cx);
571 });
572
573 let fs = FakeFs::new(cx.executor());
574 fs.insert_tree(
575 path!("/root"),
576 json!({
577 "project": {
578 "src": { "main.rs": "fn main() {}" }
579 },
580 "external": {
581 "data": { "file.txt": "content" }
582 }
583 }),
584 )
585 .await;
586
587 fs.create_symlink(
588 path!("/root/project/link_to_external").as_ref(),
589 PathBuf::from("../external"),
590 )
591 .await
592 .unwrap();
593
594 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
595 cx.executor().run_until_parked();
596
597 let action_log = cx.new(|_| ActionLog::new(project.clone()));
598 let tool = Arc::new(DeletePathTool::new(project, action_log));
599
600 let (event_stream, mut event_rx) = ToolCallEventStream::test();
601 let task = cx.update(|cx| {
602 tool.run(
603 ToolInput::resolved(DeletePathToolInput {
604 path: "project/link_to_external".into(),
605 }),
606 event_stream,
607 cx,
608 )
609 });
610
611 let auth = event_rx.expect_authorization().await;
612 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
613 assert!(
614 title.contains("points outside the project") || title.contains("symlink"),
615 "Authorization title should mention symlink escape, got: {title}",
616 );
617
618 auth.response
619 .send(acp_thread::SelectedPermissionOutcome::new(
620 acp::PermissionOptionId::new("allow"),
621 acp::PermissionOptionKind::AllowOnce,
622 ))
623 .unwrap();
624
625 assert!(
626 !matches!(
627 event_rx.try_recv(),
628 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
629 ),
630 "Expected a single authorization prompt",
631 );
632
633 let result = task.await;
634 if let Err(err) = &result {
635 let message = format!("{err:#}");
636 assert!(
637 !message.contains("denied") && !message.contains("authorization"),
638 "Error should not be a permission denial, got: {message}",
639 );
640 }
641 }
642
643 #[gpui::test]
644 async fn test_delete_path_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
645 init_test(cx);
646 cx.update(|cx| {
647 let mut settings = AgentSettings::get_global(cx).clone();
648 settings.tool_permissions.tools.insert(
649 "delete_path".into(),
650 agent_settings::ToolRules {
651 default: Some(settings::ToolPermissionMode::Deny),
652 ..Default::default()
653 },
654 );
655 AgentSettings::override_global(settings, cx);
656 });
657
658 let fs = FakeFs::new(cx.executor());
659 fs.insert_tree(
660 path!("/root"),
661 json!({
662 "project": {
663 "src": { "main.rs": "fn main() {}" }
664 },
665 "external": {
666 "data": { "file.txt": "content" }
667 }
668 }),
669 )
670 .await;
671
672 fs.create_symlink(
673 path!("/root/project/link_to_external").as_ref(),
674 PathBuf::from("../external"),
675 )
676 .await
677 .unwrap();
678
679 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
680 cx.executor().run_until_parked();
681
682 let action_log = cx.new(|_| ActionLog::new(project.clone()));
683 let tool = Arc::new(DeletePathTool::new(project, action_log));
684
685 let (event_stream, mut event_rx) = ToolCallEventStream::test();
686 let result = cx
687 .update(|cx| {
688 tool.run(
689 ToolInput::resolved(DeletePathToolInput {
690 path: "project/link_to_external".into(),
691 }),
692 event_stream,
693 cx,
694 )
695 })
696 .await;
697
698 assert!(result.is_err(), "Tool should fail when policy denies");
699 assert!(
700 !matches!(
701 event_rx.try_recv(),
702 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
703 ),
704 "Deny policy should not emit symlink authorization prompt",
705 );
706 }
707}
708