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}