Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:31:40.853Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

create_directory_tool.rs

799 lines · 29.3 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.