use super::tool_permissions::{ authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape, resolve_creatable_global_skill_path, sensitive_settings_kind, }; use agent_client_protocol::schema::v1 as acp; use agent_settings::AgentSettings; use futures::FutureExt as _; use gpui::{App, AppContext as _, AsyncApp, Entity, SharedString, Task}; use project::Project; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::Settings; use std::sync::Arc; use util::markdown::MarkdownInlineCode; use crate::{ AgentTool, ToolCallEventStream, ToolInput, ToolPermissionDecision, authorize_with_sensitive_settings, decide_permission_for_path, }; use std::path::{Path, PathBuf}; /// Creates a new directory at the specified path, and all necessary parent directories. Returns confirmation that the directory was created. /// /// Use this whenever you need to create new directories. Paths inside the project are created directly. /// #[cfg_attr( any(target_os = "linux", target_os = "macos"), doc = "This tool can also create a directory **outside** the project. When agent terminal \ commands are sandboxed, doing so grants those commands write access to exactly that new \ directory — so, rather than requesting write access to a broad existing parent (e.g. your \ home directory) just to create something inside it, create the specific directory here \ first and then write into it. The only other supported path outside the project is \ `~/.agents/skills` or a descendant, for global agent skills." )] #[cfg_attr( not(any(target_os = "linux", target_os = "macos")), doc = "The only supported path outside the project is `~/.agents/skills` or a descendant, \ for global agent skills." )] #[derive(Debug, Serialize, Deserialize, JsonSchema)] pub struct CreateDirectoryToolInput { /// The path of the new directory. /// /// /// If the project has the following structure: /// /// - directory1/ /// - directory2/ /// /// You can create a new directory by providing a path of "directory1/new_directory" /// /// /// /// To create a global agent skill directory, you may provide a path under `~/.agents/skills`, such as `~/.agents/skills/my-skill`. /// pub path: String, #[cfg_attr( any(target_os = "linux", target_os = "macos"), doc = "Justification for creating a directory **outside** the project, shown to the \ user (attributed to you) in the approval prompt that grants sandboxed terminal \ commands write access to it. Required only for out-of-project paths; ignored for \ paths inside the project or the global skills dir." )] #[cfg_attr( not(any(target_os = "linux", target_os = "macos")), doc = "Unused on this platform." )] #[serde(default)] pub reason: Option, } pub struct CreateDirectoryTool { project: Entity, } impl CreateDirectoryTool { pub fn new(project: Entity) -> Self { Self { project } } } impl AgentTool for CreateDirectoryTool { type Input = CreateDirectoryToolInput; type Output = String; const NAME: &'static str = "create_directory"; fn kind() -> acp::ToolKind { acp::ToolKind::Edit } fn initial_title( &self, input: Result, _cx: &mut App, ) -> SharedString { if let Ok(input) = input { format!("Create directory {}", MarkdownInlineCode(&input.path)).into() } else { "Create directory".into() } } fn run( self: Arc, input: ToolInput, event_stream: ToolCallEventStream, cx: &mut App, ) -> Task> { let project = self.project.clone(); cx.spawn(async move |cx| { let input = input.recv().await.map_err(|e| e.to_string())?; let fs = project.read_with(cx, |project, _cx| project.fs().clone()); // Resolve where this directory lives. The global agent-skills dir is a // special case allowed outside the project; anything else outside the // project is handled as a narrow sandbox write grant below. let global_skill_directory = resolve_creatable_global_skill_path(Path::new(&input.path), fs.as_ref()).await; let in_project = project.read_with(cx, |project, cx| { project.find_project_path(&input.path, cx).is_some() }); // A path outside the project (and not the global skills dir) can only // be created as a narrow sandbox write grant: create the directory and // grant sandboxed terminal commands write access to exactly it. The // sandbox approval prompt — which shows the real, canonicalized target // — fully replaces the normal permission and symlink-escape prompts // here. if global_skill_directory.is_none() && !in_project { return create_out_of_project_directory(&project, &input, &event_stream, cx).await; } let decision = cx.update(|cx| { decide_permission_for_path(Self::NAME, &input.path, AgentSettings::get_global(cx)) }); if let ToolPermissionDecision::Deny(reason) = decision { return Err(reason); } let destination_path: Arc = input.path.as_str().into(); let canonical_roots = canonicalize_worktree_roots(&project, &fs, cx).await; let symlink_escape_target = project.read_with(cx, |project, cx| { detect_symlink_escape(project, &input.path, &canonical_roots, cx) .map(|(_, target)| target) }); let sensitive_kind = sensitive_settings_kind(Path::new(&input.path), &canonical_roots, fs.as_ref()) .await; let decision = if matches!(decision, ToolPermissionDecision::Allow) && sensitive_kind.is_some() { ToolPermissionDecision::Confirm } else { decision }; let authorize = if let Some(canonical_target) = symlink_escape_target { // Symlink escape authorization replaces (rather than supplements) // the normal tool-permission prompt. The symlink prompt already // requires explicit user approval with the canonical target shown, // which is strictly more security-relevant than a generic confirm. Some(cx.update(|cx| { authorize_symlink_access( Self::NAME, &input.path, &canonical_target, &event_stream, cx, ) })) } else { match decision { ToolPermissionDecision::Allow => None, ToolPermissionDecision::Confirm => Some(cx.update(|cx| { let title = format!("Create directory {}", MarkdownInlineCode(&input.path)); let context = crate::ToolPermissionContext::new(Self::NAME, vec![input.path.clone()]); authorize_with_sensitive_settings( sensitive_kind, context, &title, &event_stream, cx, ) })), ToolPermissionDecision::Deny(_) => None, } }; if let Some(authorize) = authorize { authorize.await.map_err(|e| e.to_string())?; } if let Some(global_skill_directory) = global_skill_directory { futures::select! { result = fs.create_dir(&global_skill_directory).fuse() => { result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?; } _ = event_stream.cancelled_by_user().fuse() => { return Err("Create directory cancelled by user".to_string()); } } return Ok(format!("Created directory {destination_path}")); } let create_entry = project.update(cx, |project, cx| { match project.find_project_path(&input.path, cx) { Some(project_path) => Ok(project.create_entry(project_path, true, cx)), None => Err("Path to create was outside the project".to_string()), } })?; futures::select! { result = create_entry.fuse() => { result.map_err(|e| format!("Creating directory {destination_path}: {e}"))?; } _ = event_stream.cancelled_by_user().fuse() => { return Err("Create directory cancelled by user".to_string()); } } Ok(format!("Created directory {destination_path}")) }) } } /// Create a directory that lives **outside** the project by granting sandboxed /// terminal commands write access to exactly it. /// /// The directory is created (Linux: eagerly, pinning the inode; macOS: after /// approval) and the user is shown the real, canonicalized target in the sandbox /// approval prompt — which is what defends against a concurrent symlink swap: the /// grant is always against the inode/path the user actually saw. On denial, only /// the directories we created are removed. async fn create_out_of_project_directory( project: &Entity, input: &CreateDirectoryToolInput, event_stream: &ToolCallEventStream, cx: &mut AsyncApp, ) -> Result { // Narrowing a grant to a brand-new directory only makes sense when the // project's terminal commands are sandboxed, and only on platforms that can // grant a not-yet-existing directory. Otherwise keep the historical // "outside the project" rejection. let sandboxing = project.read_with(cx, |project, cx| { crate::sandboxing::sandboxing_enabled_for_project(project, cx) }); let platform_supported = cfg!(any(target_os = "linux", target_os = "macos")); if !sandboxing || !platform_supported { return Err("Path to create was outside the project".to_string()); } let Some(reason) = input .reason .as_deref() .map(str::trim) .filter(|reason| !reason.is_empty()) else { return Err( "Creating a directory outside the project grants sandboxed terminal commands write \ access to it, so a `reason` is required: briefly justify why the directory is needed, \ then try again." .to_string(), ); }; let reason = reason.to_string(); let absolute = resolve_absolute_path(project, &input.path, cx) .ok_or_else(|| format!("Couldn't resolve `{}` to an absolute path.", input.path))?; let prepared = cx .background_spawn(async move { sandbox::GrantableWriteDir::prepare(&absolute) }) .await .map_err(|error| format!("Creating directory {}: {error}", input.path))?; let canonical = prepared.canonical_path().to_path_buf(); // The directory was just created and its inode pinned, so persist the // resolved canonical alongside the raw request: enforcement rebuilds the // grant from the vetted canonical via a verifying reopen. let request = crate::sandboxing::SandboxRequest { write_paths: vec![settings::GrantedWritePath::resolved( prepared.untrusted_raw_path().to_path_buf(), canonical.clone(), )], ..Default::default() }; let approve = cx.update(|cx| event_stream.authorize_sandbox(request, reason, cx)); match approve.await { Ok(()) => { let display = canonical.display().to_string(); cx.background_spawn(async move { prepared.finalize() }) .await .map_err(|error| format!("Creating directory {display}: {error}"))?; Ok(format!("Created directory {display}")) } Err(error) => { // Roll back exactly what we created; leave the user no litter. cx.background_spawn(async move { prepared.discard() }).await; Err(format!("Create directory cancelled: {error}")) } } } /// Resolve a model-provided path to an absolute, lexically-normalized path. /// Relative paths are joined onto the first worktree root. fn resolve_absolute_path( project: &Entity, raw: &str, cx: &mut AsyncApp, ) -> Option { let path = Path::new(raw); let absolute = if path.is_absolute() { path.to_path_buf() } else { let base = project.read_with(cx, |project, cx| { project .worktrees(cx) .next() .map(|worktree| worktree.read(cx).abs_path().to_path_buf()) })?; base.join(path) }; util::paths::normalize_lexically(&absolute).ok() } #[cfg(test)] mod tests { use super::*; use fs::Fs as _; use gpui::TestAppContext; use project::{FakeFs, Project}; use serde_json::json; use settings::SettingsStore; use std::path::PathBuf; use util::path; use crate::ToolCallEventStream; fn init_test(cx: &mut TestAppContext) { cx.update(|cx| { let settings_store = SettingsStore::test(cx); cx.set_global(settings_store); }); cx.update(|cx| { let mut settings = AgentSettings::get_global(cx).clone(); settings.tool_permissions.default = settings::ToolPermissionMode::Allow; AgentSettings::override_global(settings, cx); }); } #[gpui::test] async fn test_create_directory_allows_global_skill_directory(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root/project"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); let tool = Arc::new(CreateDirectoryTool::new(project)); let input_path = PathBuf::from("~") .join(".agents") .join("skills") .join("my-skill") .to_string_lossy() .into_owned(); let created_path = agent_skills::global_skills_dir().join("my-skill"); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: input_path, reason: None, }), event_stream, cx, ) }); let auth = event_rx.expect_authorization().await; let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); assert!( title.contains("agent skills"), "Authorization title should mention agent skills, got: {title}", ); assert!( auth.options .first_option_of_kind(acp::PermissionOptionKind::AllowAlways) .is_none(), "agent skills prompt must not offer an \"Always allow\" option: {:?}", auth.options, ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), acp::PermissionOptionKind::AllowOnce, )) .expect("authorization response should send"); let result = task.await; assert!( result.is_ok(), "Tool should create global skill directory: {result:?}" ); assert!(fs.is_dir(&created_path).await); } #[gpui::test] async fn test_create_directory_rejects_other_global_paths(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root/project"), json!({})).await; let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); let tool = Arc::new(CreateDirectoryTool::new(project)); let outside_path = agent_skills::global_skills_dir() .parent() .expect("global skills directory should have a parent") .join("not-skills"); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let result = cx .update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: outside_path.to_string_lossy().into_owned(), reason: None, }), event_stream, cx, ) }) .await; assert!( result.is_err(), "Tool should reject paths outside the project and global skills directory" ); assert!(!fs.is_dir(&outside_path).await); assert!( !matches!( event_rx.try_recv(), Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Non-skill global path should not emit an agent-skills authorization prompt", ); } #[gpui::test] async fn test_create_directory_symlink_escape_requests_authorization(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "project": { "src": { "main.rs": "fn main() {}" } }, "external": { "data": { "file.txt": "content" } } }), ) .await; fs.create_symlink( path!("/root/project/link_to_external").as_ref(), PathBuf::from("../external"), ) .await .unwrap(); let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); let tool = Arc::new(CreateDirectoryTool::new(project)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), reason: None, }), event_stream, cx, ) }); let auth = event_rx.expect_authorization().await; let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); assert!( title.contains("points outside the project") || title.contains("symlink"), "Authorization title should mention symlink escape, got: {title}", ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), acp::PermissionOptionKind::AllowOnce, )) .unwrap(); let result = task.await; assert!( result.is_ok(), "Tool should succeed after authorization: {result:?}" ); } #[gpui::test] async fn test_create_directory_symlink_escape_denied(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "project": { "src": { "main.rs": "fn main() {}" } }, "external": { "data": { "file.txt": "content" } } }), ) .await; fs.create_symlink( path!("/root/project/link_to_external").as_ref(), PathBuf::from("../external"), ) .await .unwrap(); let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); let tool = Arc::new(CreateDirectoryTool::new(project)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), reason: None, }), event_stream, cx, ) }); let auth = event_rx.expect_authorization().await; drop(auth); let result = task.await; assert!( result.is_err(), "Tool should fail when authorization is denied" ); } #[gpui::test] async fn test_create_directory_symlink_escape_confirm_requires_single_approval( cx: &mut TestAppContext, ) { init_test(cx); cx.update(|cx| { let mut settings = AgentSettings::get_global(cx).clone(); settings.tool_permissions.default = settings::ToolPermissionMode::Confirm; AgentSettings::override_global(settings, cx); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "project": { "src": { "main.rs": "fn main() {}" } }, "external": { "data": { "file.txt": "content" } } }), ) .await; fs.create_symlink( path!("/root/project/link_to_external").as_ref(), PathBuf::from("../external"), ) .await .unwrap(); let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); let tool = Arc::new(CreateDirectoryTool::new(project)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let task = cx.update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), reason: None, }), event_stream, cx, ) }); let auth = event_rx.expect_authorization().await; let title = auth.tool_call.fields.title.as_deref().unwrap_or(""); assert!( title.contains("points outside the project") || title.contains("symlink"), "Authorization title should mention symlink escape, got: {title}", ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new("allow"), acp::PermissionOptionKind::AllowOnce, )) .unwrap(); assert!( !matches!( event_rx.try_recv(), Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Expected a single authorization prompt", ); let result = task.await; assert!( result.is_ok(), "Tool should succeed after one authorization: {result:?}" ); } #[gpui::test] async fn test_create_directory_symlink_escape_honors_deny_policy(cx: &mut TestAppContext) { init_test(cx); cx.update(|cx| { let mut settings = AgentSettings::get_global(cx).clone(); settings.tool_permissions.tools.insert( "create_directory".into(), agent_settings::ToolRules { default: Some(settings::ToolPermissionMode::Deny), ..Default::default() }, ); AgentSettings::override_global(settings, cx); }); let fs = FakeFs::new(cx.executor()); fs.insert_tree( path!("/root"), json!({ "project": { "src": { "main.rs": "fn main() {}" } }, "external": { "data": { "file.txt": "content" } } }), ) .await; fs.create_symlink( path!("/root/project/link_to_external").as_ref(), PathBuf::from("../external"), ) .await .unwrap(); let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); let tool = Arc::new(CreateDirectoryTool::new(project)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let result = cx .update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: "project/link_to_external".into(), reason: None, }), event_stream, cx, ) }) .await; assert!(result.is_err(), "Tool should fail when policy denies"); assert!( !matches!( event_rx.try_recv(), Ok(Ok(crate::ThreadEvent::ToolCallAuthorization(_))) ), "Deny policy should not emit symlink authorization prompt", ); } /// Out-of-project creation goes through the sandbox write-grant prompt and, /// on approval, creates the *specific* new directory (not its broad parent). #[cfg(any(target_os = "linux", target_os = "macos"))] #[gpui::test] async fn test_create_directory_out_of_project_creates_and_grants(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) .await; let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); // The sandbox create path operates on the *real* filesystem, so use a // real directory outside the (fake) project. let scratch = tempfile::tempdir().unwrap(); let target = scratch.path().join("new_grant_dir"); assert!(!target.exists()); let tool = Arc::new(CreateDirectoryTool::new(project)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let path_input = target.to_string_lossy().into_owned(); let task = cx.update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: path_input, reason: Some("scratch space for the build".into()), }), event_stream, cx, ) }); let auth = event_rx.expect_authorization().await; let details = acp_thread::sandbox_authorization_details_from_meta(&auth.tool_call.meta) .expect("out-of-project create should request a sandbox write grant"); // The grant is for exactly the new directory, not its parent, and // carries the resolved canonical established when it was created. let expected_canonical = scratch.path().canonicalize().unwrap().join("new_grant_dir"); assert_eq!(details.write_paths.len(), 1); assert_eq!( details.write_paths[0].canonical_or_requested(), expected_canonical ); auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::AllowThread.as_id()), acp::PermissionOptionKind::AllowAlways, )) .unwrap(); let result = task.await; assert!(result.is_ok(), "expected success, got {result:?}"); assert!( target.is_dir(), "the new directory should have been created" ); } /// Denying the grant removes the directory we eagerly created, leaving no /// trace on the filesystem. #[cfg(any(target_os = "linux", target_os = "macos"))] #[gpui::test] async fn test_create_directory_out_of_project_denied_cleans_up(cx: &mut TestAppContext) { init_test(cx); let fs = FakeFs::new(cx.executor()); fs.insert_tree(path!("/root"), json!({ "project": { "src": {} } })) .await; let project = Project::test(fs.clone(), [path!("/root/project").as_ref()], cx).await; cx.executor().run_until_parked(); let scratch = tempfile::tempdir().unwrap(); let target = scratch.path().join("denied_dir"); let tool = Arc::new(CreateDirectoryTool::new(project)); let (event_stream, mut event_rx) = ToolCallEventStream::test(); let path_input = target.to_string_lossy().into_owned(); let task = cx.update(|cx| { tool.run( ToolInput::resolved(CreateDirectoryToolInput { path: path_input, reason: Some("scratch space".into()), }), event_stream, cx, ) }); let auth = event_rx.expect_authorization().await; auth.response .send(acp_thread::SelectedPermissionOutcome::new( acp::PermissionOptionId::new(acp_thread::SandboxPermission::Deny.as_id()), acp::PermissionOptionKind::RejectOnce, )) .unwrap(); let result = task.await; assert!(result.is_err(), "denied create should fail"); assert!( !target.exists(), "denied create should leave no directory behind" ); } }