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}