Detailed changes
@@ -21,6 +21,7 @@ use gpui::{
http_client::FakeHttpClient,
};
use indoc::indoc;
+
use language_model::{
LanguageModel, LanguageModelCompletionError, LanguageModelCompletionEvent, LanguageModelId,
LanguageModelProviderName, LanguageModelRegistry, LanguageModelRequest,
@@ -3925,3 +3926,581 @@ async fn test_terminal_tool_permission_rules(cx: &mut TestAppContext) {
);
}
}
+
+#[gpui::test]
+async fn test_edit_file_tool_deny_rule_blocks_edit(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({"sensitive_config.txt": "secret data"}))
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "edit_file".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![agent_settings::CompiledRegex::new(r"sensitive", false).unwrap()],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ let context_server_registry =
+ cx.new(|cx| crate::ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let templates = crate::Templates::new();
+ let thread = cx.new(|cx| {
+ crate::Thread::new(
+ project.clone(),
+ cx.new(|_cx| prompt_store::ProjectContext::default()),
+ context_server_registry,
+ templates.clone(),
+ None,
+ cx,
+ )
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::EditFileTool::new(
+ project.clone(),
+ thread.downgrade(),
+ language_registry,
+ templates,
+ ));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let task = cx.update(|cx| {
+ tool.run(
+ crate::EditFileToolInput {
+ display_description: "Edit sensitive file".to_string(),
+ path: "root/sensitive_config.txt".into(),
+ mode: crate::EditFileMode::Edit,
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ let result = task.await;
+ assert!(result.is_err(), "expected edit to be blocked");
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the edit was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_delete_path_tool_deny_rule_blocks_deletion(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({"important_data.txt": "critical info"}))
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "delete_path".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![agent_settings::CompiledRegex::new(r"important", false).unwrap()],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ let action_log = cx.new(|_cx| action_log::ActionLog::new(project.clone()));
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::DeletePathTool::new(project, action_log));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let task = cx.update(|cx| {
+ tool.run(
+ crate::DeletePathToolInput {
+ path: "root/important_data.txt".to_string(),
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ let result = task.await;
+ assert!(result.is_err(), "expected deletion to be blocked");
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the deletion was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_move_path_tool_denies_if_destination_denied(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "safe.txt": "content",
+ "protected": {}
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "move_path".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![agent_settings::CompiledRegex::new(r"protected", false).unwrap()],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::MovePathTool::new(project));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let task = cx.update(|cx| {
+ tool.run(
+ crate::MovePathToolInput {
+ source_path: "root/safe.txt".to_string(),
+ destination_path: "root/protected/safe.txt".to_string(),
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ let result = task.await;
+ assert!(
+ result.is_err(),
+ "expected move to be blocked due to destination path"
+ );
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the move was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_move_path_tool_denies_if_source_denied(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "secret.txt": "secret content",
+ "public": {}
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "move_path".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![agent_settings::CompiledRegex::new(r"secret", false).unwrap()],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::MovePathTool::new(project));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let task = cx.update(|cx| {
+ tool.run(
+ crate::MovePathToolInput {
+ source_path: "root/secret.txt".to_string(),
+ destination_path: "root/public/not_secret.txt".to_string(),
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ let result = task.await;
+ assert!(
+ result.is_err(),
+ "expected move to be blocked due to source path"
+ );
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the move was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_copy_path_tool_deny_rule_blocks_copy(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "confidential.txt": "confidential data",
+ "dest": {}
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "copy_path".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![
+ agent_settings::CompiledRegex::new(r"confidential", false).unwrap(),
+ ],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::CopyPathTool::new(project));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let task = cx.update(|cx| {
+ tool.run(
+ crate::CopyPathToolInput {
+ source_path: "root/confidential.txt".to_string(),
+ destination_path: "root/dest/copy.txt".to_string(),
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ let result = task.await;
+ assert!(result.is_err(), "expected copy to be blocked");
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the copy was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_save_file_tool_denies_if_any_path_denied(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree(
+ "/root",
+ json!({
+ "normal.txt": "normal content",
+ "readonly": {
+ "config.txt": "readonly content"
+ }
+ }),
+ )
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "save_file".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![agent_settings::CompiledRegex::new(r"readonly", false).unwrap()],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::SaveFileTool::new(project));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let task = cx.update(|cx| {
+ tool.run(
+ crate::SaveFileToolInput {
+ paths: vec![
+ std::path::PathBuf::from("root/normal.txt"),
+ std::path::PathBuf::from("root/readonly/config.txt"),
+ ],
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ let result = task.await;
+ assert!(
+ result.is_err(),
+ "expected save to be blocked due to denied path"
+ );
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the save was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_save_file_tool_respects_deny_rules(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({"config.secret": "secret config"}))
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = false;
+ settings.tool_permissions.tools.insert(
+ "save_file".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![agent_settings::CompiledRegex::new(r"\.secret$", false).unwrap()],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::SaveFileTool::new(project));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let task = cx.update(|cx| {
+ tool.run(
+ crate::SaveFileToolInput {
+ paths: vec![std::path::PathBuf::from("root/config.secret")],
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ let result = task.await;
+ assert!(result.is_err(), "expected save to be blocked");
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the save was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_web_search_tool_deny_rule_blocks_search(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "web_search".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![
+ agent_settings::CompiledRegex::new(r"internal\.company", false).unwrap(),
+ ],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::WebSearchTool);
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let input: crate::WebSearchToolInput =
+ serde_json::from_value(json!({"query": "internal.company.com secrets"})).unwrap();
+
+ let task = cx.update(|cx| tool.run(input, event_stream, cx));
+
+ let result = task.await;
+ assert!(result.is_err(), "expected search to be blocked");
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the search was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_edit_file_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ let fs = FakeFs::new(cx.executor());
+ fs.insert_tree("/root", json!({"README.md": "# Hello"}))
+ .await;
+ let project = Project::test(fs.clone(), ["/root".as_ref()], cx).await;
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = false;
+ settings.tool_permissions.tools.insert(
+ "edit_file".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Confirm,
+ always_allow: vec![agent_settings::CompiledRegex::new(r"\.md$", false).unwrap()],
+ always_deny: vec![],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ let context_server_registry =
+ cx.new(|cx| crate::ContextServerRegistry::new(project.read(cx).context_server_store(), cx));
+ let language_registry = project.read_with(cx, |project, _cx| project.languages().clone());
+ let templates = crate::Templates::new();
+ let thread = cx.new(|cx| {
+ crate::Thread::new(
+ project.clone(),
+ cx.new(|_cx| prompt_store::ProjectContext::default()),
+ context_server_registry,
+ templates.clone(),
+ None,
+ cx,
+ )
+ });
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::EditFileTool::new(
+ project,
+ thread.downgrade(),
+ language_registry,
+ templates,
+ ));
+ let (event_stream, mut rx) = crate::ToolCallEventStream::test();
+
+ let _task = cx.update(|cx| {
+ tool.run(
+ crate::EditFileToolInput {
+ display_description: "Edit README".to_string(),
+ path: "root/README.md".into(),
+ mode: crate::EditFileMode::Edit,
+ },
+ event_stream,
+ cx,
+ )
+ });
+
+ cx.run_until_parked();
+
+ let event = rx.try_next();
+ assert!(
+ !matches!(event, Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(_))))),
+ "expected no authorization request for allowed .md file"
+ );
+}
+
+#[gpui::test]
+async fn test_fetch_tool_deny_rule_blocks_url(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.tool_permissions.tools.insert(
+ "fetch".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Allow,
+ always_allow: vec![],
+ always_deny: vec![
+ agent_settings::CompiledRegex::new(r"internal\.company\.com", false).unwrap(),
+ ],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ let http_client = gpui::http_client::FakeHttpClient::with_200_response();
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::FetchTool::new(http_client));
+ let (event_stream, _rx) = crate::ToolCallEventStream::test();
+
+ let input: crate::FetchToolInput =
+ serde_json::from_value(json!({"url": "https://internal.company.com/api"})).unwrap();
+
+ let task = cx.update(|cx| tool.run(input, event_stream, cx));
+
+ let result = task.await;
+ assert!(result.is_err(), "expected fetch to be blocked");
+ assert!(
+ result.unwrap_err().to_string().contains("blocked"),
+ "error should mention the fetch was blocked"
+ );
+}
+
+#[gpui::test]
+async fn test_fetch_tool_allow_rule_skips_confirmation(cx: &mut TestAppContext) {
+ init_test(cx);
+
+ cx.update(|cx| {
+ let mut settings = agent_settings::AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = false;
+ settings.tool_permissions.tools.insert(
+ "fetch".into(),
+ agent_settings::ToolRules {
+ default_mode: settings::ToolPermissionMode::Confirm,
+ always_allow: vec![agent_settings::CompiledRegex::new(r"docs\.rs", false).unwrap()],
+ always_deny: vec![],
+ always_confirm: vec![],
+ invalid_patterns: vec![],
+ },
+ );
+ agent_settings::AgentSettings::override_global(settings, cx);
+ });
+
+ let http_client = gpui::http_client::FakeHttpClient::with_200_response();
+
+ #[allow(clippy::arc_with_non_send_sync)]
+ let tool = Arc::new(crate::FetchTool::new(http_client));
+ let (event_stream, mut rx) = crate::ToolCallEventStream::test();
+
+ let input: crate::FetchToolInput =
+ serde_json::from_value(json!({"url": "https://docs.rs/some-crate"})).unwrap();
+
+ let _task = cx.update(|cx| tool.run(input, event_stream, cx));
+
+ cx.run_until_parked();
+
+ let event = rx.try_next();
+ assert!(
+ !matches!(event, Ok(Some(Ok(ThreadEvent::ToolCallAuthorization(_))))),
+ "expected no authorization request for allowed docs.rs URL"
+ );
+}
@@ -1,11 +1,15 @@
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+};
use agent_client_protocol::ToolKind;
+use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use futures::FutureExt as _;
use gpui::{App, AppContext, Entity, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+use settings::Settings;
use std::sync::Arc;
use util::markdown::MarkdownInlineCode;
@@ -79,6 +83,31 @@ impl AgentTool for CopyPathTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
+ let settings = AgentSettings::get_global(cx);
+
+ let source_decision =
+ decide_permission_from_settings(Self::name(), &input.source_path, settings);
+ if let ToolPermissionDecision::Deny(reason) = source_decision {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+
+ let dest_decision =
+ decide_permission_from_settings(Self::name(), &input.destination_path, settings);
+ if let ToolPermissionDecision::Deny(reason) = dest_decision {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+
+ let needs_confirmation = matches!(source_decision, ToolPermissionDecision::Confirm)
+ || matches!(dest_decision, ToolPermissionDecision::Confirm);
+
+ let authorize = if needs_confirmation {
+ let src = MarkdownInlineCode(&input.source_path);
+ let dest = MarkdownInlineCode(&input.destination_path);
+ Some(event_stream.authorize(format!("Copy {src} to {dest}"), cx))
+ } else {
+ None
+ };
+
let copy_task = self.project.update(cx, |project, cx| {
match project
.find_project_path(&input.source_path, cx)
@@ -99,6 +128,10 @@ impl AgentTool for CopyPathTool {
});
cx.background_spawn(async move {
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
+
let result = futures::select! {
result = copy_task.fuse() => result,
_ = event_stream.cancelled_by_user().fuse() => {
@@ -1,14 +1,18 @@
use agent_client_protocol::ToolKind;
+use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
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};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+};
/// Creates a new directory at the specified path within the project. Returns confirmation that the directory was created.
///
@@ -68,6 +72,20 @@ impl AgentTool for CreateDirectoryTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
+ let settings = AgentSettings::get_global(cx);
+ let decision = decide_permission_from_settings(Self::name(), &input.path, settings);
+
+ let authorize = match decision {
+ ToolPermissionDecision::Allow => None,
+ ToolPermissionDecision::Deny(reason) => {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+ ToolPermissionDecision::Confirm => Some(event_stream.authorize(
+ format!("Create directory {}", MarkdownInlineCode(&input.path)),
+ cx,
+ )),
+ };
+
let project_path = match self.project.read(cx).find_project_path(&input.path, cx) {
Some(project_path) => project_path,
None => {
@@ -81,6 +99,10 @@ impl AgentTool for CreateDirectoryTool {
});
cx.spawn(async move |_cx| {
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
+
futures::select! {
result = create_entry.fuse() => {
result.with_context(|| format!("Creating directory {destination_path}"))?;
@@ -1,13 +1,18 @@
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+};
use action_log::ActionLog;
use agent_client_protocol::ToolKind;
+use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use futures::{FutureExt as _, SinkExt, StreamExt, channel::mpsc};
use gpui::{App, AppContext, Entity, SharedString, Task};
use project::{Project, ProjectPath};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+use settings::Settings;
use std::sync::Arc;
+use util::markdown::MarkdownInlineCode;
/// Deletes the file or directory (and the directory's contents, recursively) at the specified path in the project, and returns confirmation of the deletion.
#[derive(Debug, Serialize, Deserialize, JsonSchema)]
@@ -71,6 +76,20 @@ impl AgentTool for DeletePathTool {
cx: &mut App,
) -> Task<Result<Self::Output>> {
let path = input.path;
+
+ let settings = AgentSettings::get_global(cx);
+ let decision = decide_permission_from_settings(Self::name(), &path, settings);
+
+ let authorize = match decision {
+ ToolPermissionDecision::Allow => None,
+ ToolPermissionDecision::Deny(reason) => {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+ ToolPermissionDecision::Confirm => {
+ Some(event_stream.authorize(format!("Delete {}", MarkdownInlineCode(&path)), cx))
+ }
+ };
+
let Some(project_path) = self.project.read(cx).find_project_path(&path, cx) else {
return Task::ready(Err(anyhow!(
"Couldn't delete {path} because that path isn't in this project."
@@ -113,6 +132,10 @@ impl AgentTool for DeletePathTool {
let project = self.project.clone();
let action_log = self.action_log.clone();
cx.spawn(async move |cx| {
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
+
loop {
let path_result = futures::select! {
path = paths_rx.next().fuse() => path,
@@ -1,5 +1,6 @@
use crate::{
- AgentTool, Templates, Thread, ToolCallEventStream,
+ AgentTool, Templates, Thread, ToolCallEventStream, ToolPermissionDecision,
+ decide_permission_from_settings,
edit_agent::{EditAgent, EditAgentOutput, EditAgentOutputEvent, EditFormat},
};
use acp_thread::Diff;
@@ -149,8 +150,16 @@ impl EditFileTool {
event_stream: &ToolCallEventStream,
cx: &mut App,
) -> Task<Result<()>> {
- if agent_settings::AgentSettings::get_global(cx).always_allow_tool_actions {
- return Task::ready(Ok(()));
+ let path_str = input.path.to_string_lossy();
+ let settings = agent_settings::AgentSettings::get_global(cx);
+ let decision = decide_permission_from_settings(Self::name(), &path_str, settings);
+
+ match decision {
+ ToolPermissionDecision::Allow => return Task::ready(Ok(())),
+ ToolPermissionDecision::Deny(reason) => {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+ ToolPermissionDecision::Confirm => {}
}
// If any path component matches the local settings folder, then this could affect
@@ -3,6 +3,7 @@ use std::sync::Arc;
use std::{borrow::Cow, cell::RefCell};
use agent_client_protocol as acp;
+use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, bail};
use futures::{AsyncReadExt as _, FutureExt as _};
use gpui::{App, AppContext as _, Task};
@@ -10,10 +11,13 @@ use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
use http_client::{AsyncBody, HttpClientWithUrl};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+use settings::Settings;
use ui::SharedString;
-use util::markdown::MarkdownEscaped;
+use util::markdown::{MarkdownEscaped, MarkdownInlineCode};
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+};
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
enum ContentType {
@@ -143,12 +147,25 @@ impl AgentTool for FetchTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
- let authorize = event_stream.authorize(input.url.clone(), cx);
+ let settings = AgentSettings::get_global(cx);
+ let decision = decide_permission_from_settings(Self::name(), &input.url, settings);
+
+ let authorize = match decision {
+ ToolPermissionDecision::Allow => None,
+ ToolPermissionDecision::Deny(reason) => {
+ return Task::ready(Err(anyhow::anyhow!("{}", reason)));
+ }
+ ToolPermissionDecision::Confirm => Some(
+ event_stream.authorize(format!("Fetch {}", MarkdownInlineCode(&input.url)), cx),
+ ),
+ };
let fetch_task = cx.background_spawn({
let http_client = self.http_client.clone();
async move {
- authorize.await?;
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
Self::build_message(http_client, &input.url).await
}
});
@@ -1,11 +1,15 @@
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+};
use agent_client_protocol::ToolKind;
+use agent_settings::AgentSettings;
use anyhow::{Context as _, Result, anyhow};
use futures::FutureExt as _;
use gpui::{App, AppContext, Entity, SharedString, Task};
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+use settings::Settings;
use std::{path::Path, sync::Arc};
use util::markdown::MarkdownInlineCode;
@@ -93,6 +97,31 @@ impl AgentTool for MovePathTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
+ let settings = AgentSettings::get_global(cx);
+
+ let source_decision =
+ decide_permission_from_settings(Self::name(), &input.source_path, settings);
+ if let ToolPermissionDecision::Deny(reason) = source_decision {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+
+ let dest_decision =
+ decide_permission_from_settings(Self::name(), &input.destination_path, settings);
+ if let ToolPermissionDecision::Deny(reason) = dest_decision {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+
+ let needs_confirmation = matches!(source_decision, ToolPermissionDecision::Confirm)
+ || matches!(dest_decision, ToolPermissionDecision::Confirm);
+
+ let authorize = if needs_confirmation {
+ let src = MarkdownInlineCode(&input.source_path);
+ let dest = MarkdownInlineCode(&input.destination_path);
+ Some(event_stream.authorize(format!("Move {src} to {dest}"), cx))
+ } else {
+ None
+ };
+
let rename_task = self.project.update(cx, |project, cx| {
match project
.find_project_path(&input.source_path, cx)
@@ -113,6 +142,10 @@ impl AgentTool for MovePathTool {
});
cx.background_spawn(async move {
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
+
let result = futures::select! {
result = rename_task.fuse() => result,
_ = event_stream.cancelled_by_user().fuse() => {
@@ -1,4 +1,5 @@
use agent_client_protocol as acp;
+use agent_settings::AgentSettings;
use anyhow::Result;
use collections::FxHashSet;
use futures::FutureExt as _;
@@ -7,10 +8,14 @@ use language::Buffer;
use project::Project;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+use settings::Settings;
use std::path::PathBuf;
use std::sync::Arc;
+use util::markdown::MarkdownInlineCode;
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+};
/// Saves files that have unsaved changes.
///
@@ -62,10 +67,59 @@ impl AgentTool for SaveFileTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<String>> {
+ let settings = AgentSettings::get_global(cx);
+ let mut needs_confirmation = false;
+
+ for path in &input.paths {
+ let path_str = path.to_string_lossy();
+ let decision = decide_permission_from_settings(Self::name(), &path_str, settings);
+ match decision {
+ ToolPermissionDecision::Allow => {}
+ ToolPermissionDecision::Deny(reason) => {
+ return Task::ready(Err(anyhow::anyhow!("{}", reason)));
+ }
+ ToolPermissionDecision::Confirm => {
+ needs_confirmation = true;
+ }
+ }
+ }
+
+ let authorize = if needs_confirmation {
+ let title = if input.paths.len() == 1 {
+ format!(
+ "Save {}",
+ MarkdownInlineCode(&input.paths[0].to_string_lossy())
+ )
+ } else {
+ let paths: Vec<_> = input
+ .paths
+ .iter()
+ .take(3)
+ .map(|p| p.to_string_lossy().to_string())
+ .collect();
+ if input.paths.len() > 3 {
+ format!(
+ "Save {}, and {} more",
+ paths.join(", "),
+ input.paths.len() - 3
+ )
+ } else {
+ format!("Save {}", paths.join(", "))
+ }
+ };
+ Some(event_stream.authorize(title, cx))
+ } else {
+ None
+ };
+
let project = self.project.clone();
let input_paths = input.paths;
cx.spawn(async move |cx| {
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
+
let mut buffers_to_save: FxHashSet<Entity<Buffer>> = FxHashSet::default();
let mut saved_paths: Vec<PathBuf> = Vec::new();
@@ -196,6 +250,11 @@ mod tests {
let settings_store = SettingsStore::test(cx);
cx.set_global(settings_store);
});
+ cx.update(|cx| {
+ let mut settings = AgentSettings::get_global(cx).clone();
+ settings.always_allow_tool_actions = true;
+ AgentSettings::override_global(settings, cx);
+ });
}
#[gpui::test]
@@ -112,7 +112,7 @@ impl AgentTool for TerminalTool {
};
let settings = AgentSettings::get_global(cx);
- let decision = decide_permission_from_settings("terminal", &input.command, settings);
+ let decision = decide_permission_from_settings(Self::name(), &input.command, settings);
let authorize = match decision {
ToolPermissionDecision::Allow => None,
@@ -1,7 +1,10 @@
use std::sync::Arc;
-use crate::{AgentTool, ToolCallEventStream};
+use crate::{
+ AgentTool, ToolCallEventStream, ToolPermissionDecision, decide_permission_from_settings,
+};
use agent_client_protocol as acp;
+use agent_settings::AgentSettings;
use anyhow::{Result, anyhow};
use cloud_llm_client::WebSearchResponse;
use futures::FutureExt as _;
@@ -11,7 +14,9 @@ use language_model::{
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
+use settings::Settings;
use ui::prelude::*;
+use util::markdown::MarkdownInlineCode;
use web_search::WebSearchRegistry;
/// Search the web for information using your query.
@@ -68,12 +73,30 @@ impl AgentTool for WebSearchTool {
event_stream: ToolCallEventStream,
cx: &mut App,
) -> Task<Result<Self::Output>> {
+ let settings = AgentSettings::get_global(cx);
+ let decision = decide_permission_from_settings(Self::name(), &input.query, settings);
+
+ let authorize = match decision {
+ ToolPermissionDecision::Allow => None,
+ ToolPermissionDecision::Deny(reason) => {
+ return Task::ready(Err(anyhow!("{}", reason)));
+ }
+ ToolPermissionDecision::Confirm => Some(event_stream.authorize(
+ format!("Search the web for {}", MarkdownInlineCode(&input.query)),
+ cx,
+ )),
+ };
+
let Some(provider) = WebSearchRegistry::read_global(cx).active_provider() else {
return Task::ready(Err(anyhow!("Web search is not available.")));
};
let search_task = provider.search(input.query, cx);
cx.background_spawn(async move {
+ if let Some(authorize) = authorize {
+ authorize.await?;
+ }
+
let response = futures::select! {
result = search_task.fuse() => {
match result {