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