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_thread::SelectedPermissionOutcome::new(
245 acp::PermissionOptionId::new("allow"),
246 acp::PermissionOptionKind::AllowOnce,
247 ))
248 .unwrap();
249
250 let result = task.await;
251 assert!(
252 result.is_ok(),
253 "Tool should succeed after authorization: {result:?}"
254 );
255 }
256
257 #[gpui::test]
258 async fn test_create_directory_symlink_escape_denied(cx: &mut TestAppContext) {
259 init_test(cx);
260
261 let fs = FakeFs::new(cx.executor());
262 fs.insert_tree(
263 path!("/root"),
264 json!({
265 "project": {
266 "src": { "main.rs": "fn main() {}" }
267 },
268 "external": {
269 "data": { "file.txt": "content" }
270 }
271 }),
272 )
273 .await;
274
275 fs.create_symlink(
276 path!("/root/project/link_to_external").as_ref(),
277 PathBuf::from("../external"),
278 )
279 .await
280 .unwrap();
281
282 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
283 cx.executor().run_until_parked();
284
285 let tool = Arc::new(CreateDirectoryTool::new(project));
286
287 let (event_stream, mut event_rx) = ToolCallEventStream::test();
288 let task = cx.update(|cx| {
289 tool.run(
290 ToolInput::resolved(CreateDirectoryToolInput {
291 path: "project/link_to_external".into(),
292 }),
293 event_stream,
294 cx,
295 )
296 });
297
298 let auth = event_rx.expect_authorization().await;
299
300 drop(auth);
301
302 let result = task.await;
303 assert!(
304 result.is_err(),
305 "Tool should fail when authorization is denied"
306 );
307 }
308
309 #[gpui::test]
310 async fn test_create_directory_symlink_escape_confirm_requires_single_approval(
311 cx: &mut TestAppContext,
312 ) {
313 init_test(cx);
314 cx.update(|cx| {
315 let mut settings = AgentSettings::get_global(cx).clone();
316 settings.tool_permissions.default = settings::ToolPermissionMode::Confirm;
317 AgentSettings::override_global(settings, cx);
318 });
319
320 let fs = FakeFs::new(cx.executor());
321 fs.insert_tree(
322 path!("/root"),
323 json!({
324 "project": {
325 "src": { "main.rs": "fn main() {}" }
326 },
327 "external": {
328 "data": { "file.txt": "content" }
329 }
330 }),
331 )
332 .await;
333
334 fs.create_symlink(
335 path!("/root/project/link_to_external").as_ref(),
336 PathBuf::from("../external"),
337 )
338 .await
339 .unwrap();
340
341 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
342 cx.executor().run_until_parked();
343
344 let tool = Arc::new(CreateDirectoryTool::new(project));
345
346 let (event_stream, mut event_rx) = ToolCallEventStream::test();
347 let task = cx.update(|cx| {
348 tool.run(
349 ToolInput::resolved(CreateDirectoryToolInput {
350 path: "project/link_to_external".into(),
351 }),
352 event_stream,
353 cx,
354 )
355 });
356
357 let auth = event_rx.expect_authorization().await;
358 let title = auth.tool_call.fields.title.as_deref().unwrap_or("");
359 assert!(
360 title.contains("points outside the project") || title.contains("symlink"),
361 "Authorization title should mention symlink escape, got: {title}",
362 );
363
364 auth.response
365 .send(acp_thread::SelectedPermissionOutcome::new(
366 acp::PermissionOptionId::new("allow"),
367 acp::PermissionOptionKind::AllowOnce,
368 ))
369 .unwrap();
370
371 assert!(
372 !matches!(
373 event_rx.try_recv(),
374 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
375 ),
376 "Expected a single authorization prompt",
377 );
378
379 let result = task.await;
380 assert!(
381 result.is_ok(),
382 "Tool should succeed after one authorization: {result:?}"
383 );
384 }
385
386 #[gpui::test]
387 async fn test_create_directory_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) {
388 init_test(cx);
389 cx.update(|cx| {
390 let mut settings = AgentSettings::get_global(cx).clone();
391 settings.tool_permissions.tools.insert(
392 "create_directory".into(),
393 agent_settings::ToolRules {
394 default: Some(settings::ToolPermissionMode::Deny),
395 ..Default::default()
396 },
397 );
398 AgentSettings::override_global(settings, cx);
399 });
400
401 let fs = FakeFs::new(cx.executor());
402 fs.insert_tree(
403 path!("/root"),
404 json!({
405 "project": {
406 "src": { "main.rs": "fn main() {}" }
407 },
408 "external": {
409 "data": { "file.txt": "content" }
410 }
411 }),
412 )
413 .await;
414
415 fs.create_symlink(
416 path!("/root/project/link_to_external").as_ref(),
417 PathBuf::from("../external"),
418 )
419 .await
420 .unwrap();
421
422 let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await;
423 cx.executor().run_until_parked();
424
425 let tool = Arc::new(CreateDirectoryTool::new(project));
426
427 let (event_stream, mut event_rx) = ToolCallEventStream::test();
428 let result = cx
429 .update(|cx| {
430 tool.run(
431 ToolInput::resolved(CreateDirectoryToolInput {
432 path: "project/link_to_external".into(),
433 }),
434 event_stream,
435 cx,
436 )
437 })
438 .await;
439
440 assert!(result.is_err(), "Tool should fail when policy denies");
441 assert!(
442 !matches!(
443 event_rx.try_recv(),
444 Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_)))
445 ),
446 "Deny policy should not emit symlink authorization prompt",
447 );
448 }
449}