445 lines · 14.3 KB · Rust
RawDownload
use super::tool_permissions::{    authorize_symlink_access, canonicalize_worktree_roots, detect_symlink_escape,    sensitive_settings_kind,};use agent_client_protocol::schema as acp;use agent_settings::AgentSettings;use futures::FutureExt as _;use gpui::{App, 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; /// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.////// This tool creates a directory and all necessary parent directories. It should be used whenever you need to create new directories within the project.#[derive(Debug, Serialize, Deserialize, JsonSchema)]pub struct CreateDirectoryToolInput {    /// The path of the new directory.    ///    /// <example>    /// If the project has the following structure:    ///    /// - directory1/    /// - directory2/    ///    /// You can create a new directory by providing a path of "directory1/new_directory"    /// </example>    pub path: String,} pub struct CreateDirectoryTool {    project: Entity<Project>,} impl CreateDirectoryTool {    pub fn new(project: Entity<Project>) -> 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<Self::Input, serde_json::Value>,        _cx: &mut App,    ) -> SharedString {        if let Ok(input) = input {            format!("Create directory {}", MarkdownInlineCode(&input.path)).into()        } else {            "Create directory".into()        }    }     fn run(        self: Arc<Self>,        input: ToolInput<Self::Input>,        event_stream: ToolCallEventStream,        cx: &mut App,    ) -> Task<Result<Self::Output, Self::Output>> {        let project = self.project.clone();        cx.spawn(async move |cx| {            let input = input.recv().await.map_err(|e| e.to_string())?;            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<str> = input.path.as_str().into();             let fs = project.read_with(cx, |project, _cx| project.fs().clone());            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), 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())?;            }             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}"))        })    }} #[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_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(),                }),                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(),                }),                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(),                }),                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(),                    }),                    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",        );    }}