Skip to repository content799 lines · 29.3 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T02:34:05.119Z 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
create_directory_tool.rs
1use super::tool_permissions::{
2 authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape,
3 resolve_creatable_global_skill_path, sensitive_settings_kind,
4};
5use agent_client_protocol::schema::v1 as acp;
6use agent_settings::AgentSettings;
7use futures::FutureExt as _;
8use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task};
9use project::Project;
10use schemars::JsonSchema;
11use serde::{Deserialize, Serialize};
12use settings::Settings;
13use std::sync::Arc;
14use util::markdown::MarkdownInlineCode;
15
16use crate::{
17 AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision,
18 authorize_with_sensitive_settings, decide_permission_for_path,
19};
20use std::path::{Path, PathBuf};
21
22/// Creates a new directory at the specified path, and all necessary parent directories. Returns confirmation that the directory was created.
23///
24/// Use this whenever you need to create new directories. Paths inside the project are created directly.
25///
26/// This tool can also create a directory **outside** the project. When agent terminal commands are sandboxed, doing so grants those commands write access to exactly that new directory — so, rather than requesting write access to a broad existing parent (e.g. your home directory) just to create something inside it, create the specific directory here first and then write into it. The only other supported path outside the project is `~/.agents/skills` or a descendant, for global agent skills.
27#[derive(Debug, Serialize, Deserialize, JsonSchema)]
28pub struct CreateDirectoryToolInput {
29 /// The path of the new directory.
30 ///
31 /// <example>
32 /// If the project has the following structure:
33 ///
34 /// - directory1/
35 /// - directory2/
36 ///
37 /// You can create a new directory by providing a path of "directory1/new_directory"
38 /// </example>
39 ///
40 /// <example>
41 /// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`.
42 /// </example>
43 pub path: String,
44
45 /// Justification for creating a directory **outside** the project, shown to
46 /// the user (attributed to you) in the approval prompt that grants sandboxed
47 /// terminal commands write access to it. Required only for out-of-project
48 /// paths; ignored for paths inside the project or the global skills dir.
49 #[serde(default)]
50 pub reason: Option<String>,
51}
52
53pub struct CreateDirectoryTool {
54 project: Entity<Project>,
55}
56
57impl CreateDirectoryTool {
58 pub fn new(project: Entity<Project>) -> Self {
59 Self { project }
60 }
61}
62
63impl AgentTool for CreateDirectoryTool {
64 type Input = CreateDirectoryToolInput;
65 type Output = String;
66
67 const NAME: &'static str = "create_directory";
68
69 fn kind() -> acp::ToolKind {
70 acp::ToolKind::Edit
71 }
72
73 fn initial_title(
74 &self,
75 input: Result<Self::Input, serde_json::Value>,
76 _cx: &mut App,
77 ) -> SharedString {
78 if let Ok(input) = input {
79 format!("Create directory {}", MarkdownInlineCode(&input.path)).into()
80 } else {
81 "Create directory".into()
82 }
83 }
84
85 fn run(
86 self: Arc<Self>,
87 input: ToolInput<Self::Input>,
88 event_stream: ToolCallEventStream,
89 cx: &mut App,
90 ) -> Task<Result<Self::Output, Self::Output>> {
91 let project = self.project.clone();
92 cx.spawn(async move |cx| {
93 let input = input.recv().await.map_err(|e| e.to_string())?;
94
95 let fs = project.read_with(cx, |project, _cx| project.fs().clone());
96
97 // Resolve where this directory lives. The global agent-skills dir is a
98 // special case allowed outside the project; anything else outside the
99 // project is handled as a narrow sandbox write grant below.
100 let global_skill_directory =
101 resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await;
102 let in_project = project.read_with(cx, |project, cx| {
103 project.find_project_path(&input.path, cx).is_some()
104 });
105
106 // A path outside the project (and not the global skills dir) can only
107 // be created as a narrow sandbox write grant: create the directory and
108 // grant sandboxed terminal commands write access to exactly it. The
109 // sandbox approval prompt — which shows the real, canonicalized target
110 // — fully replaces the normal permission and symlink-escape prompts
111 // here.
112 if global_skill_directory.is_none() && !in_project {
113 return create_out_of_project_directory(&project, &input, &event_stream, cx).await;
114 }
115
116 let decision = cx.update(|cx| {
117 decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx))
118 });
119
120 if let ToolPermissionDecision::Deny(reason) = decision {
121 return Err(reason);
122 }
123
124 let destination_path: Arc<str> = input.path.as_str().into();
125
126 let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
127
128 let symlink_escape_target = project.read_with(cx, |project, cx| {
129 detect_symlink_escape(project, &input.path, &canonical_roots, cx)
130 .map(|(_, target)| target)
131 });
132
133 let sensitive_kind =
134 sensitive_settings_kind(Path::new(&input.path), &canonical_roots, fs.as_ref())
135 .await;
136
137 let decision =
138 if matches!(decision, ToolPermissionDecision::Allow) && sensitive_kind.is_some() {
139 ToolPermissionDecision::Confirm
140 } else {
141 decision
142 };
143
144 let authorize = if let Some(canonical_target) = symlink_escape_target {
145 // Symlink escape authorization replaces (rather than supplements)
146 // the normal tool-permission prompt. The symlink prompt already
147 // requires explicit user approval with the canonical target shown,
148 // which is strictly more security-relevant than a generic confirm.
149 Some(cx.update(|cx| {
150 authorize_symlink_access(
151 Self::NAME,
152 &input.path,
153 &canonical_target,
154 &event_stream,
155 cx,
156 )
157 }))
158 } else {
159 match decision {
160 ToolPermissionDecision::Allow => None,
161 ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
162 let title = format!("Create directory {}", MarkdownInlineCode(&input.path));
163 let context =
164 crate::ToolPermissionContext::new(Self::NAME, vec![input.path.clone()]);
165 authorize_with_sensitive_settings(
166 sensitive_kind,
167 context,
168 &title,
169 &event_stream,
170 cx,
171 )
172 })),
173 ToolPermissionDecision::Deny(_) => None,
174 }
175 };
176
177 if let Some(authorize) = authorize {
178 authorize.await.map_err(|e| e.to_string())?;
179 }
180
181 if let Some(global_skill_directory) = global_skill_directory {
182 futures::select! {
183 result = fs.create_dir(&global_skill_directory).fuse() => {
184 result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?;
185 }
186 _ = event_stream.cancelled_by_user().fuse() => {
187 return Err("Create directory cancelled by user".to_string());
188 }
189 }
190
191 return Ok(format!("Created directory {destination_path}"));
192 }
193
194 let create_entry = project.update(cx, |project, cx| {
195 match project.find_project_path(&input.path, cx) {
196 Some(project_path) => Ok(project.create_entry(project_path, true, cx)),
197 None => Err("Path to create was outside the project".to_string()),
198 }
199 })?;
200
201 futures::select! {
202 result = create_entry.fuse() => {
203 result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?;
204 }
205 _ = event_stream.cancelled_by_user().fuse() => {
206 return Err("Create directory cancelled by user".to_string());
207 }
208 }
209
210 Ok(format!("Created directory {destination_path}"))
211 })
212 }
213}
214
215/// Create a directory that lives **outside** the project by granting sandboxed
216/// terminal commands write access to exactly it.
217///
218/// The directory is created (Linux: eagerly, pinning the inode; macOS: after
219/// approval) and the user is shown the real, canonicalized target in the sandbox
220/// approval prompt — which is what defends against a concurrent symlink swap: the
221/// grant is always against the inode/path the user actually saw. On denial, only
222/// the directories we created are removed.
223async fn create_out_of_project_directory(
224 project: &Entity<Project>,
225 input: &CreateDirectoryToolInput,
226 event_stream: &ToolCallEventStream,
227 cx: &mut AsyncApp,
228) -> Result<String, String> {
229 // Narrowing a grant to a brand-new directory only makes sense when the
230 // project's terminal commands are sandboxed, and only on platforms that can
231 // grant a not-yet-existing directory. Otherwise keep the historical
232 // "outside the project" rejection.
233 let sandboxing = project.read_with(cx, |project, cx| {
234 crate::sandboxing::sandboxing_enabled_for_project(project, cx)
235 });
236 let platform_supported = cfg!(any(target_os = "linux", target_os = "macos"));
237 if !sandboxing || !platform_supported {
238 return Err("Path to create was outside the project".to_string());
239 }
240
241 let Some(reason) = input
242 .reason
243 .as_deref()
244 .map(str::trim)
245 .filter(|reason| !reason.is_empty())
246 else {
247 return Err(
248 "Creating a directory outside the project grants sandboxed terminal commands write \
249 access to it, so a `reason` is required: briefly justify why the directory is needed, \
250 then try again."
251 .to_string(),
252 );
253 };
254 let reason = reason.to_string();
255
256 let absolute = resolve_absolute_path(project, &input.path, cx)
257 .ok_or_else(|| format!("Couldn't resolve `{}` to an absolute path.", input.path))?;
258
259 let prepared = cx
260 .background_spawn(async move { sandbox::GrantableWriteDir::prepare(&absolute) })
261 .await
262 .map_err(|error| format!("Creating directory {}: {error}", input.path))?;
263
264 let canonical = prepared.canonical_path().to_path_buf();
265 let request = crate::sandboxing::SandboxRequest {
266 write_paths: vec![canonical.clone()],
267 ..Default::default()
268 };
269
270 let approve = cx.update(|cx| event_stream.authorize_sandbox(request, reason, cx));
271 match approve.await {
272 Ok(()) => {
273 let display = canonical.display().to_string();
274 cx.background_spawn(async move { prepared.finalize() })
275 .await
276 .map_err(|error| format!("Creating directory {display}: {error}"))?;
277 Ok(format!("Created directory {display}"))
278 }
279 Err(error) => {
280 // Roll back exactly what we created; leave the user no litter.
281 cx.background_spawn(async move { prepared.discard() }).await;
282 Err(format!("Create directory cancelled: {error}"))
283 }
284 }
285}
286
287/// Resolve a model-provided path to an absolute, lexically-normalized path.
288/// Relative paths are joined onto the first worktree root.
289fn resolve_absolute_path(
290 project: &Entity<Project>,
291 raw: &str,
292 cx: &mut AsyncApp,
293) -> Option<PathBuf> {
294 let path = Path::new(raw);
295 let absolute = if path.is_absolute() {
296 path.to_path_buf()
297 } else {
298 let base = project.read_with(cx, |project, cx| {
299 project
300 .worktrees(cx)
301 .next()
302 .map(|worktree| worktree.read(cx).abs_path().to_path_buf())
303 })?;
304 base.join(path)
305 };
306 util::paths::normalize_lexically(&absolute).ok()
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use fs::Fs as _;
313 use gpui::TestAppContext;
314 use project::{FakeFs, Project};
315 use serde_json::json;
316 use settings::SettingsStore;
317 use std::path::PathBuf;
318 use util::path;
319
320 use crate::ToolCallEventStream;
321
322 fn init_test(cx: &mut TestAppContext) {
323 cx.update(|cx| {
324 let settings_store = SettingsStore::test(cx);
325 cx.set_global(settings_store);
326 });
327 cx.update(|cx| {
328 let mut settings = AgentSettings::get_global(cx).clone();
329 settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
330 AgentSettings::override_global(settings, cx);
331 });
332 }
333
334 #[gpui::test]
335 async fn test_create_directory_allows_global_skill_directory(cx: &mut TestAppContext) {
336 init_test(cx);
337
338 let fs = FakeFs::new(cx.executor());
339 fs.insert_tree(path!("/root/project"), json!({})).await;
340 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
341 cx.executor().run_until_parked();
342
343 let tool = Arc::new(CreateDirectoryTool::new(project));
344 let input_path = PathBuf::from("~")
345 .join(".agents")
346 .join("skills")
347 .join("my-skill")
348 .to_string_lossy()
349 .into_owned();
350 let created_path = agent_skills::global_skills_dir().join("my-skill");
351
352 let (event_stream, mut event_rx) = ToolCallEventStream::test();
353 let task = cx.update(|cx| {
354 tool.run(
355 ToolInput::resolved(CreateDirectoryToolInput {
356 path: input_path,
357 reason: None,
358 }),
359 event_stream,
360 cx,
361 )
362 });
363
364 let auth = event_rx.expect_authorization().await;
365 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
366 assert!(
367 title.contains("agent skills"),
368 "Authorization title should mention agent skills, got: {title}",
369 );
370 assert!(
371 auth.options
372 .first_option_of_kind(acp::PermissionOptionKind::AllowAlways)
373 .is_none(),
374 "agent skills prompt must not offer an \"Always allow\" option: {:?}",
375 auth.options,
376 );
377 auth.response
378 .send(acp_thread::SelectedPermissionOutcome::new(
379 acp::PermissionOptionId::new("allow"),
380 acp::PermissionOptionKind::AllowOnce,
381 ))
382 .expect("authorization response should send");
383
384 let result = task.await;
385 assert!(
386 result.is_ok(),
387 "Tool should create global skill directory: {result:?}"
388 );
389 assert!(fs.is_dir(&created_path).await);
390 }
391
392 #[gpui::test]
393 async fn test_create_directory_rejects_other_global_paths(cx: &mut TestAppContext) {
394 init_test(cx);
395
396 let fs = FakeFs::new(cx.executor());
397 fs.insert_tree(path!("/root/project"), json!({})).await;
398 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
399 cx.executor().run_until_parked();
400
401 let tool = Arc::new(CreateDirectoryTool::new(project));
402 let outside_path = agent_skills::global_skills_dir()
403 .parent()
404 .expect("global skills directory should have a parent")
405 .join("not-skills");
406
407 let (event_stream, mut event_rx) = ToolCallEventStream::test();
408 let result = cx
409 .update(|cx| {
410 tool.run(
411 ToolInput::resolved(CreateDirectoryToolInput {
412 path: outside_path.to_string_lossy().into_owned(),
413 reason: None,
414 }),
415 event_stream,
416 cx,
417 )
418 })
419 .await;
420
421 assert!(
422 result.is_err(),
423 "Tool should reject paths outside the project and global skills directory"
424 );
425 assert!(!fs.is_dir(&outside_path).await);
426 assert!(
427 !matches!(
428 event_rx.try_recv(),
429 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
430 ),
431 "Non-skill global path should not emit an agent-skills authorization prompt",
432 );
433 }
434
435 #[gpui::test]
436 async fn test_create_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
437 init_test(cx);
438
439 let fs = FakeFs::new(cx.executor());
440 fs.insert_tree(
441 path!("/root"),
442 json!({
443 "project": {
444 "src": { "main.rs": "fn main() {}" }
445 },
446 "external": {
447 "data": { "file.txt": "content" }
448 }
449 }),
450 )
451 .await;
452
453 fs.create_symlink(
454 path!("/root/project/link_to_external").as_ref(),
455 PathBuf::from("../external"),
456 )
457 .await
458 .unwrap();
459
460 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
461 cx.executor().run_until_parked();
462
463 let tool = Arc::new(CreateDirectoryTool::new(project));
464
465 let (event_stream, mut event_rx) = ToolCallEventStream::test();
466 let task = cx.update(|cx| {
467 tool.run(
468 ToolInput::resolved(CreateDirectoryToolInput {
469 path: "project/link_to_external".into(),
470 reason: None,
471 }),
472 event_stream,
473 cx,
474 )
475 });
476
477 let auth = event_rx.expect_authorization().await;
478 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
479 assert!(
480 title.contains("points outside the project") || title.contains("symlink"),
481 "Authorization title should mention symlink escape, got: {title}",
482 );
483
484 auth.response
485 .send(acp_thread::SelectedPermissionOutcome::new(
486 acp::PermissionOptionId::new("allow"),
487 acp::PermissionOptionKind::AllowOnce,
488 ))
489 .unwrap();
490
491 let result = task.await;
492 assert!(
493 result.is_ok(),
494 "Tool should succeed after authorization: {result:?}"
495 );
496 }
497
498 #[gpui::test]
499 async fn test_create_directory_symlink_escape_denied(cx: &mut TestAppContext) {
500 init_test(cx);
501
502 let fs = FakeFs::new(cx.executor());
503 fs.insert_tree(
504 path!("/root"),
505 json!({
506 "project": {
507 "src": { "main.rs": "fn main() {}" }
508 },
509 "external": {
510 "data": { "file.txt": "content" }
511 }
512 }),
513 )
514 .await;
515
516 fs.create_symlink(
517 path!("/root/project/link_to_external").as_ref(),
518 PathBuf::from("../external"),
519 )
520 .await
521 .unwrap();
522
523 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
524 cx.executor().run_until_parked();
525
526 let tool = Arc::new(CreateDirectoryTool::new(project));
527
528 let (event_stream, mut event_rx) = ToolCallEventStream::test();
529 let task = cx.update(|cx| {
530 tool.run(
531 ToolInput::resolved(CreateDirectoryToolInput {
532 path: "project/link_to_external".into(),
533 reason: None,
534 }),
535 event_stream,
536 cx,
537 )
538 });
539
540 let auth = event_rx.expect_authorization().await;
541
542 drop(auth);
543
544 let result = task.await;
545 assert!(
546 result.is_err(),
547 "Tool should fail when authorization is denied"
548 );
549 }
550
551 #[gpui::test]
552 async fn test_create_directory_symlink_escape_confirm_requires_single_approval(
553 cx: &mut TestAppContext,
554 ) {
555 init_test(cx);
556 cx.update(|cx| {
557 let mut settings = AgentSettings::get_global(cx).clone();
558 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
559 AgentSettings::override_global(settings, cx);
560 });
561
562 let fs = FakeFs::new(cx.executor());
563 fs.insert_tree(
564 path!("/root"),
565 json!({
566 "project": {
567 "src": { "main.rs": "fn main() {}" }
568 },
569 "external": {
570 "data": { "file.txt": "content" }
571 }
572 }),
573 )
574 .await;
575
576 fs.create_symlink(
577 path!("/root/project/link_to_external").as_ref(),
578 PathBuf::from("../external"),
579 )
580 .await
581 .unwrap();
582
583 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
584 cx.executor().run_until_parked();
585
586 let tool = Arc::new(CreateDirectoryTool::new(project));
587
588 let (event_stream, mut event_rx) = ToolCallEventStream::test();
589 let task = cx.update(|cx| {
590 tool.run(
591 ToolInput::resolved(CreateDirectoryToolInput {
592 path: "project/link_to_external".into(),
593 reason: None,
594 }),
595 event_stream,
596 cx,
597 )
598 });
599
600 let auth = event_rx.expect_authorization().await;
601 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
602 assert!(
603 title.contains("points outside the project") || title.contains("symlink"),
604 "Authorization title should mention symlink escape, got: {title}",
605 );
606
607 auth.response
608 .send(acp_thread::SelectedPermissionOutcome::new(
609 acp::PermissionOptionId::new("allow"),
610 acp::PermissionOptionKind::AllowOnce,
611 ))
612 .unwrap();
613
614 assert!(
615 !matches!(
616 event_rx.try_recv(),
617 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
618 ),
619 "Expected a single authorization prompt",
620 );
621
622 let result = task.await;
623 assert!(
624 result.is_ok(),
625 "Tool should succeed after one authorization: {result:?}"
626 );
627 }
628
629 #[gpui::test]
630 async fn test_create_directory_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
631 init_test(cx);
632 cx.update(|cx| {
633 let mut settings = AgentSettings::get_global(cx).clone();
634 settings.tool_permissions.tools.insert(
635 "create_directory".into(),
636 agent_settings::ToolRules {
637 default: Some(settings::ToolPermissionMode::Deny),
638 ..Default::default()
639 },
640 );
641 AgentSettings::override_global(settings, cx);
642 });
643
644 let fs = FakeFs::new(cx.executor());
645 fs.insert_tree(
646 path!("/root"),
647 json!({
648 "project": {
649 "src": { "main.rs": "fn main() {}" }
650 },
651 "external": {
652 "data": { "file.txt": "content" }
653 }
654 }),
655 )
656 .await;
657
658 fs.create_symlink(
659 path!("/root/project/link_to_external").as_ref(),
660 PathBuf::from("../external"),
661 )
662 .await
663 .unwrap();
664
665 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
666 cx.executor().run_until_parked();
667
668 let tool = Arc::new(CreateDirectoryTool::new(project));
669
670 let (event_stream, mut event_rx) = ToolCallEventStream::test();
671 let result = cx
672 .update(|cx| {
673 tool.run(
674 ToolInput::resolved(CreateDirectoryToolInput {
675 path: "project/link_to_external".into(),
676 reason: None,
677 }),
678 event_stream,
679 cx,
680 )
681 })
682 .await;
683
684 assert!(result.is_err(), "Tool should fail when policy denies");
685 assert!(
686 !matches!(
687 event_rx.try_recv(),
688 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
689 ),
690 "Deny policy should not emit symlink authorization prompt",
691 );
692 }
693
694 /// Out-of-project creation goes through the sandbox write-grant prompt and,
695 /// on approval, creates the *specific* new directory (not its broad parent).
696 #[cfg(any(target_os = "linux", target_os = "macos"))]
697 #[gpui::test]
698 #[ignore]
699 async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) {
700 init_test(cx);
701
702 let fs = FakeFs::new(cx.executor());
703 fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } }))
704 .await;
705 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
706 cx.executor().run_until_parked();
707
708 // The sandbox create path operates on the *real* filesystem, so use a
709 // real directory outside the (fake) project.
710 let scratch = tempfile::tempdir().unwrap();
711 let target = scratch.path().join("new_grant_dir");
712 assert!(!target.exists());
713
714 let tool = Arc::new(CreateDirectoryTool::new(project));
715 let (event_stream, mut event_rx) = ToolCallEventStream::test();
716 let path_input = target.to_string_lossy().into_owned();
717 let task = cx.update(|cx| {
718 tool.run(
719 ToolInput::resolved(CreateDirectoryToolInput {
720 path: path_input,
721 reason: Some("scratch space for the build".into()),
722 }),
723 event_stream,
724 cx,
725 )
726 });
727
728 let auth = event_rx.expect_authorization().await;
729 let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta)
730 .expect("out-of-project create should request a sandbox write grant");
731 // The grant is for exactly the new directory, not its parent.
732 assert_eq!(
733 details.write_paths,
734 vec![scratch.path().canonicalize().unwrap().join("new_grant_dir")]
735 );
736
737 auth.response
738 .send(acp_thread::SelectedPermissionOutcome::new(
739 acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()),
740 acp::PermissionOptionKind::AllowAlways,
741 ))
742 .unwrap();
743
744 let result = task.await;
745 assert!(result.is_ok(), "expected success, got {result:?}");
746 assert!(
747 target.is_dir(),
748 "the new directory should have been created"
749 );
750 }
751
752 /// Denying the grant removes the directory we eagerly created, leaving no
753 /// trace on the filesystem.
754 #[cfg(any(target_os = "linux", target_os = "macos"))]
755 #[gpui::test]
756 #[ignore]
757 async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) {
758 init_test(cx);
759
760 let fs = FakeFs::new(cx.executor());
761 fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } }))
762 .await;
763 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
764 cx.executor().run_until_parked();
765
766 let scratch = tempfile::tempdir().unwrap();
767 let target = scratch.path().join("denied_dir");
768
769 let tool = Arc::new(CreateDirectoryTool::new(project));
770 let (event_stream, mut event_rx) = ToolCallEventStream::test();
771 let path_input = target.to_string_lossy().into_owned();
772 let task = cx.update(|cx| {
773 tool.run(
774 ToolInput::resolved(CreateDirectoryToolInput {
775 path: path_input,
776 reason: Some("scratch space".into()),
777 }),
778 event_stream,
779 cx,
780 )
781 });
782
783 let auth = event_rx.expect_authorization().await;
784 auth.response
785 .send(acp_thread::SelectedPermissionOutcome::new(
786 acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()),
787 acp::PermissionOptionKind::RejectOnce,
788 ))
789 .unwrap();
790
791 let result = task.await;
792 assert!(result.is_err(), "denied create should fail");
793 assert!(
794 !target.exists(),
795 "denied create should leave no directory behind"
796 );
797 }
798}
799