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