mirror of
https://github.com/zed-industries/zed.git
synced 2026-05-31 19:05:00 +07:00
Fixes #55436 The create-directory agent tool was classified as a read tool, which caused the agent UI to render it with the search icon. This PR marks `create_directory` as an edit tool and gives it a folder-add icon in the conversation tool card. Creating a directory mutates the project, so treating it as an edit tool better matches the behavior and avoids the misleading search icon. Validation: - `cargo build -p zed` - `cargo test -p agent test_create_directory_tool_kind_is_edit -- --nocapture` - `git diff --check` - Manually verified in a local dev build that asking the agent to create `tmp- zed-icon-test-dir` shows a folder icon instead of a search icon, and that the directory is created successfully. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/ zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed create-directory agent tool cards showing a search icon. <img width="593" height="281" alt="07547c9f-2cf2-432b-8c0f-86f336dce3c5" src="https://github.com/user-attachments/assets/a5a277ee-56c5-4801-a7d8-27c347222169" /> <img width="594" height="157" alt="2b3f2b62-4782-42f1-a20d-c26f9cfe8940" src="https://github.com/user-attachments/assets/37845785-5145-4701-9060-1dd1bdb60801" /> --------- Co-authored-by: Ben Brandt <benjamin.j.brandt@gmail.com>
445 lines
14 KiB
Rust
445 lines
14 KiB
Rust
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",
|
|
);
|
|
}
|
|
}
|