create_directory_tool.rs

  1use super::tool_permissions::{
  2    SensitiveSettingsKind, authorize_symlink_access, canonicalize_worktree_roots,
  3    detect_symlink_escape, sensitive_settings_kind,
  4};
  5use agent_client_protocol::ToolKind;
  6use agent_settings::AgentSettings;
  7use futures::FutureExt as _;
  8use gpui::{App, 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, decide_permission_for_path,
 18};
 19use std::path::Path;
 20
 21/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.
 22///
 23/// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project.
 24#[derive(Debug, Serialize, Deserialize, JsonSchema)]
 25pub struct CreateDirectoryToolInput {
 26    /// The path of the new directory.
 27    ///
 28    /// <example>
 29    /// If the project has the following structure:
 30    ///
 31    /// - directory1/
 32    /// - directory2/
 33    ///
 34    /// You can create a new directory by providing a path of "directory1/new_directory"
 35    /// </example>
 36    pub path: String,
 37}
 38
 39pub struct CreateDirectoryTool {
 40    project: Entity<Project>,
 41}
 42
 43impl CreateDirectoryTool {
 44    pub fn new(project: Entity<Project>) -> Self {
 45        Self { project }
 46    }
 47}
 48
 49impl AgentTool for CreateDirectoryTool {
 50    type Input = CreateDirectoryToolInput;
 51    type Output = String;
 52
 53    const NAME: &'static str = "create_directory";
 54
 55    fn kind() -> ToolKind {
 56        ToolKind::Read
 57    }
 58
 59    fn initial_title(
 60        &self,
 61        input: Result<Self::Input, serde_json::Value>,
 62        _cx: &mut App,
 63    ) -> SharedString {
 64        if let Ok(input) = input {
 65            format!("Create directory {}", MarkdownInlineCode(&input.path)).into()
 66        } else {
 67            "Create directory".into()
 68        }
 69    }
 70
 71    fn run(
 72        self: Arc<Self>,
 73        input: ToolInput<Self::Input>,
 74        event_stream: ToolCallEventStream,
 75        cx: &mut App,
 76    ) -> Task<Result<Self::Output, Self::Output>> {
 77        let project = self.project.clone();
 78        cx.spawn(async move |cx| {
 79            let input = input
 80                .recv()
 81                .await
 82                .map_err(|e| format!("Failed to receive tool input: {e}"))?;
 83            let decision = cx.update(|cx| {
 84                decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx))
 85            });
 86
 87            if let ToolPermissionDecision::Deny(reason) = decision {
 88                return Err(reason);
 89            }
 90
 91            let destination_path: Arc<str> = input.path.as_str().into();
 92
 93            let fs = project.read_with(cx, |project, _cx| project.fs().clone());
 94            let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await;
 95
 96            let symlink_escape_target = project.read_with(cx, |project, cx| {
 97                detect_symlink_escape(project, &input.path, &canonical_roots, cx)
 98                    .map(|(_, target)| target)
 99            });
100
101            let sensitive_kind = sensitive_settings_kind(Path::new(&input.path), fs.as_ref()).await;
102
103            let decision =
104                if matches!(decision, ToolPermissionDecision::Allow) && sensitive_kind.is_some() {
105                    ToolPermissionDecision::Confirm
106                } else {
107                    decision
108                };
109
110            let authorize = if let Some(canonical_target) = symlink_escape_target {
111                // Symlink escape authorization replaces (rather than supplements)
112                // the normal tool-permission prompt. The symlink prompt already
113                // requires explicit user approval with the canonical target shown,
114                // which is strictly more security-relevant than a generic confirm.
115                Some(cx.update(|cx| {
116                    authorize_symlink_access(
117                        Self::NAME,
118                        &input.path,
119                        &canonical_target,
120                        &event_stream,
121                        cx,
122                    )
123                }))
124            } else {
125                match decision {
126                    ToolPermissionDecision::Allow => None,
127                    ToolPermissionDecision::Confirm => Some(cx.update(|cx| {
128                        let title = format!("Create directory {}", MarkdownInlineCode(&input.path));
129                        let title = match &sensitive_kind {
130                            Some(SensitiveSettingsKind::Local) => {
131                                format!("{title} (local settings)")
132                            }
133                            Some(SensitiveSettingsKind::Global) => format!("{title} (settings)"),
134                            None => title,
135                        };
136                        let context =
137                            crate::ToolPermissionContext::new(Self::NAME, vec![input.path.clone()]);
138                        event_stream.authorize(title, context, cx)
139                    })),
140                    ToolPermissionDecision::Deny(_) => None,
141                }
142            };
143
144            if let Some(authorize) = authorize {
145                authorize.await.map_err(|e| e.to_string())?;
146            }
147
148            let create_entry = project.update(cx, |project, cx| {
149                match project.find_project_path(&input.path, cx) {
150                    Some(project_path) => Ok(project.create_entry(project_path, true, cx)),
151                    None => Err("Path to create was outside the project".to_string()),
152                }
153            })?;
154
155            futures::select! {
156                result = create_entry.fuse() => {
157                    result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?;
158                }
159                _ = event_stream.cancelled_by_user().fuse() => {
160                    return Err("Create directory cancelled by user".to_string());
161                }
162            }
163
164            Ok(format!("Created directory {destination_path}"))
165        })
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use agent_client_protocol as acp;
173    use fs::Fs as _;
174    use gpui::TestAppContext;
175    use project::{FakeFs, Project};
176    use serde_json::json;
177    use settings::SettingsStore;
178    use std::path::PathBuf;
179    use util::path;
180
181    use crate::ToolCallEventStream;
182
183    fn init_test(cx: &mut TestAppContext) {
184        cx.update(|cx| {
185            let settings_store = SettingsStore::test(cx);
186            cx.set_global(settings_store);
187        });
188        cx.update(|cx| {
189            let mut settings = AgentSettings::get_global(cx).clone();
190            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
191            AgentSettings::override_global(settings, cx);
192        });
193    }
194
195    #[gpui::test]
196    async fn test_create_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
197        init_test(cx);
198
199        let fs = FakeFs::new(cx.executor());
200        fs.insert_tree(
201            path!("/root"),
202            json!({
203                "project": {
204                    "src": { "main.rs": "fn main() {}" }
205                },
206                "external": {
207                    "data": { "file.txt": "content" }
208                }
209            }),
210        )
211        .await;
212
213        fs.create_symlink(
214            path!("/root/project/link_to_external").as_ref(),
215            PathBuf::from("../external"),
216        )
217        .await
218        .unwrap();
219
220        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
221        cx.executor().run_until_parked();
222
223        let tool = Arc::new(CreateDirectoryTool::new(project));
224
225        let (event_stream, mut event_rx) = ToolCallEventStream::test();
226        let task = cx.update(|cx| {
227            tool.run(
228                ToolInput::resolved(CreateDirectoryToolInput {
229                    path: "project/link_to_external".into(),
230                }),
231                event_stream,
232                cx,
233            )
234        });
235
236        let auth = event_rx.expect_authorization().await;
237        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
238        assert!(
239            title.contains("points outside the project") || title.contains("symlink"),
240            "Authorization title should mention symlink escape, got: {title}",
241        );
242
243        auth.response
244            .send(acp::PermissionOptionId::new("allow"))
245            .unwrap();
246
247        let result = task.await;
248        assert!(
249            result.is_ok(),
250            "Tool should succeed after authorization: {result:?}"
251        );
252    }
253
254    #[gpui::test]
255    async fn test_create_directory_symlink_escape_denied(cx: &mut TestAppContext) {
256        init_test(cx);
257
258        let fs = FakeFs::new(cx.executor());
259        fs.insert_tree(
260            path!("/root"),
261            json!({
262                "project": {
263                    "src": { "main.rs": "fn main() {}" }
264                },
265                "external": {
266                    "data": { "file.txt": "content" }
267                }
268            }),
269        )
270        .await;
271
272        fs.create_symlink(
273            path!("/root/project/link_to_external").as_ref(),
274            PathBuf::from("../external"),
275        )
276        .await
277        .unwrap();
278
279        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
280        cx.executor().run_until_parked();
281
282        let tool = Arc::new(CreateDirectoryTool::new(project));
283
284        let (event_stream, mut event_rx) = ToolCallEventStream::test();
285        let task = cx.update(|cx| {
286            tool.run(
287                ToolInput::resolved(CreateDirectoryToolInput {
288                    path: "project/link_to_external".into(),
289                }),
290                event_stream,
291                cx,
292            )
293        });
294
295        let auth = event_rx.expect_authorization().await;
296
297        drop(auth);
298
299        let result = task.await;
300        assert!(
301            result.is_err(),
302            "Tool should fail when authorization is denied"
303        );
304    }
305
306    #[gpui::test]
307    async fn test_create_directory_symlink_escape_confirm_requires_single_approval(
308        cx: &mut TestAppContext,
309    ) {
310        init_test(cx);
311        cx.update(|cx| {
312            let mut settings = AgentSettings::get_global(cx).clone();
313            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
314            AgentSettings::override_global(settings, cx);
315        });
316
317        let fs = FakeFs::new(cx.executor());
318        fs.insert_tree(
319            path!("/root"),
320            json!({
321                "project": {
322                    "src": { "main.rs": "fn main() {}" }
323                },
324                "external": {
325                    "data": { "file.txt": "content" }
326                }
327            }),
328        )
329        .await;
330
331        fs.create_symlink(
332            path!("/root/project/link_to_external").as_ref(),
333            PathBuf::from("../external"),
334        )
335        .await
336        .unwrap();
337
338        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
339        cx.executor().run_until_parked();
340
341        let tool = Arc::new(CreateDirectoryTool::new(project));
342
343        let (event_stream, mut event_rx) = ToolCallEventStream::test();
344        let task = cx.update(|cx| {
345            tool.run(
346                ToolInput::resolved(CreateDirectoryToolInput {
347                    path: "project/link_to_external".into(),
348                }),
349                event_stream,
350                cx,
351            )
352        });
353
354        let auth = event_rx.expect_authorization().await;
355        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
356        assert!(
357            title.contains("points outside the project") || title.contains("symlink"),
358            "Authorization title should mention symlink escape, got: {title}",
359        );
360
361        auth.response
362            .send(acp::PermissionOptionId::new("allow"))
363            .unwrap();
364
365        assert!(
366            !matches!(
367                event_rx.try_next(),
368                Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
369            ),
370            "Expected a single authorization prompt",
371        );
372
373        let result = task.await;
374        assert!(
375            result.is_ok(),
376            "Tool should succeed after one authorization: {result:?}"
377        );
378    }
379
380    #[gpui::test]
381    async fn test_create_directory_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
382        init_test(cx);
383        cx.update(|cx| {
384            let mut settings = AgentSettings::get_global(cx).clone();
385            settings.tool_permissions.tools.insert(
386                "create_directory".into(),
387                agent_settings::ToolRules {
388                    default: Some(settings::ToolPermissionMode::Deny),
389                    ..Default::default()
390                },
391            );
392            AgentSettings::override_global(settings, cx);
393        });
394
395        let fs = FakeFs::new(cx.executor());
396        fs.insert_tree(
397            path!("/root"),
398            json!({
399                "project": {
400                    "src": { "main.rs": "fn main() {}" }
401                },
402                "external": {
403                    "data": { "file.txt": "content" }
404                }
405            }),
406        )
407        .await;
408
409        fs.create_symlink(
410            path!("/root/project/link_to_external").as_ref(),
411            PathBuf::from("../external"),
412        )
413        .await
414        .unwrap();
415
416        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
417        cx.executor().run_until_parked();
418
419        let tool = Arc::new(CreateDirectoryTool::new(project));
420
421        let (event_stream, mut event_rx) = ToolCallEventStream::test();
422        let result = cx
423            .update(|cx| {
424                tool.run(
425                    ToolInput::resolved(CreateDirectoryToolInput {
426                        path: "project/link_to_external".into(),
427                    }),
428                    event_stream,
429                    cx,
430                )
431            })
432            .await;
433
434        assert!(result.is_err(), "Tool should fail when policy denies");
435        assert!(
436            !matches!(
437                event_rx.try_next(),
438                Ok(Some(Ok(crate::ThreadEvent::ToolCallAuthorization(_))))
439            ),
440            "Deny policy should not emit symlink authorization prompt",
441        );
442    }
443}