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::schema as acp;
  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() -> acp::ToolKind {
 56        acp::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 fs::Fs as _;
173    use gpui::TestAppContext;
174    use project::{FakeFs, Project};
175    use serde_json::json;
176    use settings::SettingsStore;
177    use std::path::PathBuf;
178    use util::path;
179
180    use crate::ToolCallEventStream;
181
182    fn init_test(cx: &mut TestAppContext) {
183        cx.update(|cx| {
184            let settings_store = SettingsStore::test(cx);
185            cx.set_global(settings_store);
186        });
187        cx.update(|cx| {
188            let mut settings = AgentSettings::get_global(cx).clone();
189            settings.tool_permissions.default = settings::ToolPermissionMode::Allow;
190            AgentSettings::override_global(settings, cx);
191        });
192    }
193
194    #[gpui::test]
195    async fn test_create_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) {
196        init_test(cx);
197
198        let fs = FakeFs::new(cx.executor());
199        fs.insert_tree(
200            path!("/root"),
201            json!({
202                "project": {
203                    "src": { "main.rs": "fn main() {}" }
204                },
205                "external": {
206                    "data": { "file.txt": "content" }
207                }
208            }),
209        )
210        .await;
211
212        fs.create_symlink(
213            path!("/root/project/link_to_external").as_ref(),
214            PathBuf::from("../external"),
215        )
216        .await
217        .unwrap();
218
219        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
220        cx.executor().run_until_parked();
221
222        let tool = Arc::new(CreateDirectoryTool::new(project));
223
224        let (event_stream, mut event_rx) = ToolCallEventStream::test();
225        let task = cx.update(|cx| {
226            tool.run(
227                ToolInput::resolved(CreateDirectoryToolInput {
228                    path: "project/link_to_external".into(),
229                }),
230                event_stream,
231                cx,
232            )
233        });
234
235        let auth = event_rx.expect_authorization().await;
236        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
237        assert!(
238            title.contains("points outside the project") || title.contains("symlink"),
239            "Authorization title should mention symlink escape, got: {title}",
240        );
241
242        auth.response
243            .send(acp_thread::SelectedPermissionOutcome::new(
244                acp::PermissionOptionId::new("allow"),
245                acp::PermissionOptionKind::AllowOnce,
246            ))
247            .unwrap();
248
249        let result = task.await;
250        assert!(
251            result.is_ok(),
252            "Tool should succeed after authorization: {result:?}"
253        );
254    }
255
256    #[gpui::test]
257    async fn test_create_directory_symlink_escape_denied(cx: &mut TestAppContext) {
258        init_test(cx);
259
260        let fs = FakeFs::new(cx.executor());
261        fs.insert_tree(
262            path!("/root"),
263            json!({
264                "project": {
265                    "src": { "main.rs": "fn main() {}" }
266                },
267                "external": {
268                    "data": { "file.txt": "content" }
269                }
270            }),
271        )
272        .await;
273
274        fs.create_symlink(
275            path!("/root/project/link_to_external").as_ref(),
276            PathBuf::from("../external"),
277        )
278        .await
279        .unwrap();
280
281        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
282        cx.executor().run_until_parked();
283
284        let tool = Arc::new(CreateDirectoryTool::new(project));
285
286        let (event_stream, mut event_rx) = ToolCallEventStream::test();
287        let task = cx.update(|cx| {
288            tool.run(
289                ToolInput::resolved(CreateDirectoryToolInput {
290                    path: "project/link_to_external".into(),
291                }),
292                event_stream,
293                cx,
294            )
295        });
296
297        let auth = event_rx.expect_authorization().await;
298
299        drop(auth);
300
301        let result = task.await;
302        assert!(
303            result.is_err(),
304            "Tool should fail when authorization is denied"
305        );
306    }
307
308    #[gpui::test]
309    async fn test_create_directory_symlink_escape_confirm_requires_single_approval(
310        cx: &mut TestAppContext,
311    ) {
312        init_test(cx);
313        cx.update(|cx| {
314            let mut settings = AgentSettings::get_global(cx).clone();
315            settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
316            AgentSettings::override_global(settings, cx);
317        });
318
319        let fs = FakeFs::new(cx.executor());
320        fs.insert_tree(
321            path!("/root"),
322            json!({
323                "project": {
324                    "src": { "main.rs": "fn main() {}" }
325                },
326                "external": {
327                    "data": { "file.txt": "content" }
328                }
329            }),
330        )
331        .await;
332
333        fs.create_symlink(
334            path!("/root/project/link_to_external").as_ref(),
335            PathBuf::from("../external"),
336        )
337        .await
338        .unwrap();
339
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
345        let (event_stream, mut event_rx) = ToolCallEventStream::test();
346        let task = cx.update(|cx| {
347            tool.run(
348                ToolInput::resolved(CreateDirectoryToolInput {
349                    path: "project/link_to_external".into(),
350                }),
351                event_stream,
352                cx,
353            )
354        });
355
356        let auth = event_rx.expect_authorization().await;
357        let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
358        assert!(
359            title.contains("points outside the project") || title.contains("symlink"),
360            "Authorization title should mention symlink escape, got: {title}",
361        );
362
363        auth.response
364            .send(acp_thread::SelectedPermissionOutcome::new(
365                acp::PermissionOptionId::new("allow"),
366                acp::PermissionOptionKind::AllowOnce,
367            ))
368            .unwrap();
369
370        assert!(
371            !matches!(
372                event_rx.try_recv(),
373                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
374            ),
375            "Expected a single authorization prompt",
376        );
377
378        let result = task.await;
379        assert!(
380            result.is_ok(),
381            "Tool should succeed after one authorization: {result:?}"
382        );
383    }
384
385    #[gpui::test]
386    async fn test_create_directory_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
387        init_test(cx);
388        cx.update(|cx| {
389            let mut settings = AgentSettings::get_global(cx).clone();
390            settings.tool_permissions.tools.insert(
391                "create_directory".into(),
392                agent_settings::ToolRules {
393                    default: Some(settings::ToolPermissionMode::Deny),
394                    ..Default::default()
395                },
396            );
397            AgentSettings::override_global(settings, cx);
398        });
399
400        let fs = FakeFs::new(cx.executor());
401        fs.insert_tree(
402            path!("/root"),
403            json!({
404                "project": {
405                    "src": { "main.rs": "fn main() {}" }
406                },
407                "external": {
408                    "data": { "file.txt": "content" }
409                }
410            }),
411        )
412        .await;
413
414        fs.create_symlink(
415            path!("/root/project/link_to_external").as_ref(),
416            PathBuf::from("../external"),
417        )
418        .await
419        .unwrap();
420
421        let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
422        cx.executor().run_until_parked();
423
424        let tool = Arc::new(CreateDirectoryTool::new(project));
425
426        let (event_stream, mut event_rx) = ToolCallEventStream::test();
427        let result = cx
428            .update(|cx| {
429                tool.run(
430                    ToolInput::resolved(CreateDirectoryToolInput {
431                        path: "project/link_to_external".into(),
432                    }),
433                    event_stream,
434                    cx,
435                )
436            })
437            .await;
438
439        assert!(result.is_err(), "Tool should fail when policy denies");
440        assert!(
441            !matches!(
442                event_rx.try_recv(),
443                Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
444            ),
445            "Deny policy should not emit symlink authorization prompt",
446        );
447    }
448}