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}