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