diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 370feaa8bf..4aa0659c97 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -2,6 +2,7 @@ mod agent; mod capabilities; pub mod extension; mod factory; +mod permission_judge; mod reference; mod truncate; @@ -9,3 +10,4 @@ pub use agent::Agent; pub use capabilities::Capabilities; pub use extension::ExtensionConfig; pub use factory::{register_agent, AgentFactory}; +pub use permission_judge::detect_read_only_tools; diff --git a/crates/goose/src/agents/permission_judge.rs b/crates/goose/src/agents/permission_judge.rs new file mode 100644 index 0000000000..a1fb833673 --- /dev/null +++ b/crates/goose/src/agents/permission_judge.rs @@ -0,0 +1,265 @@ +use crate::agents::capabilities::Capabilities; +use crate::message::{Message, MessageContent, ToolRequest}; +use chrono::Utc; +use indoc::indoc; +use mcp_core::{tool::Tool, TextContent}; +use serde_json::{json, Value}; + +/// Creates the tool definition for checking read-only permissions. +fn create_read_only_tool() -> Tool { + Tool::new( + "platform__tool_by_tool_permission".to_string(), + indoc! {r#" + Analyze the tool requests and determine which ones perform read-only operations. + + What constitutes a read-only operation: + - A read-only operation retrieves information without modifying any data or state. + - Examples include: + - Reading a file without writing to it. + - Querying a database without making updates. + - Retrieving information from APIs without performing POST, PUT, or DELETE operations. + + Examples of read vs. write operations: + - Read Operations: + - `SELECT` query in SQL. + - Reading file metadata or content. + - Listing directory contents. + - Write Operations: + - `INSERT`, `UPDATE`, or `DELETE` in SQL. + - Writing or appending to a file. + - Modifying system configurations. + + How to analyze tool requests: + - Inspect each tool request to identify its purpose based on its name and arguments. + - Categorize the operation as read-only if it does not involve any state or data modification. + - Return a list of tool names that are strictly read-only. + + Use this analysis to generate the list of tools performing read-only operations from the provided tool requests. + "#} + .to_string(), + json!({ + "type": "object", + "properties": { + "read_only_tools": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Optional list of tool names which has read-only operations." + } + }, + "required": [] + }), + ) +} + +/// Builds the message to be sent to the LLM for detecting read-only operations. +fn create_check_messages(tool_requests: Vec<&ToolRequest>) -> Vec { + let mut check_messages = vec![]; + check_messages.push(Message { + role: mcp_core::Role::User, + created: Utc::now().timestamp(), + content: vec![MessageContent::Text(TextContent { + text: format!( + "Here are the tool requests: {:?}\n\nAnalyze the tool requests and list the tools that perform read-only operations. \ + \n\nGuidelines for Read-Only Operations: \ + \n- Read-only operations do not modify any data or state. \ + \n- Examples include file reading, SELECT queries in SQL, and directory listing. \ + \n- Write operations include INSERT, UPDATE, DELETE, and file writing. \ + \n\nPlease provide a list of tool names that qualify as read-only:", + tool_requests, + ), + annotations: None, + })], + }); + check_messages +} + +/// Processes the response to extract the list of tools with read-only operations. +fn extract_read_only_tools(response: &Message) -> Option> { + for content in &response.content { + if let MessageContent::ToolRequest(tool_request) = content { + if let Ok(tool_call) = &tool_request.tool_call { + if tool_call.name == "platform__tool_by_tool_permission" { + if let Value::Object(arguments) = &tool_call.arguments { + if let Some(Value::Array(read_only_tools)) = + arguments.get("read_only_tools") + { + return Some( + read_only_tools + .iter() + .filter_map(|tool| tool.as_str().map(String::from)) + .collect(), + ); + } + } + } + } + } + } + None +} + +/// Executes the read-only tools detection and returns the list of tools with read-only operations. +pub async fn detect_read_only_tools( + capabilities: &Capabilities, + tool_requests: Vec<&ToolRequest>, +) -> Vec { + if tool_requests.is_empty() { + return vec![]; + } + let tool = create_read_only_tool(); + let check_messages = create_check_messages(tool_requests); + + let res = capabilities + .provider() + .complete( + "You are a good analyst and can detect operations whether they have read-only operations.", + &check_messages, + &[tool.clone()], + ) + .await; + + // Process the response and return an empty vector if the response is invalid + if let Ok((message, _usage)) = res { + extract_read_only_tools(&message).unwrap_or_default() + } else { + vec![] + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::agents::capabilities::Capabilities; + use crate::message::{Message, MessageContent, ToolRequest}; + use crate::model::ModelConfig; + use crate::providers::base::{Provider, ProviderMetadata, ProviderUsage, Usage}; + use crate::providers::errors::ProviderError; + use chrono::Utc; + use mcp_core::ToolCall; + use mcp_core::{tool::Tool, Role, ToolResult}; + use serde_json::json; + + #[derive(Clone)] + struct MockProvider { + model_config: ModelConfig, + } + + #[async_trait::async_trait] + impl Provider for MockProvider { + fn metadata() -> ProviderMetadata { + ProviderMetadata::empty() + } + + fn get_model_config(&self) -> ModelConfig { + self.model_config.clone() + } + + async fn complete( + &self, + _system: &str, + _messages: &[Message], + _tools: &[Tool], + ) -> anyhow::Result<(Message, ProviderUsage), ProviderError> { + Ok(( + Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::ToolRequest(ToolRequest { + id: "mock_tool_request".to_string(), + tool_call: ToolResult::Ok(ToolCall { + name: "platform__tool_by_tool_permission".to_string(), + arguments: json!({ + "read_only_tools": ["file_reader", "data_fetcher"] + }), + }), + })], + }, + ProviderUsage::new("mock".to_string(), Usage::default()), + )) + } + } + + fn create_mock_capabilities() -> Capabilities { + let mock_model_config = + ModelConfig::new("test-model".to_string()).with_context_limit(200_000.into()); + Capabilities::new(Box::new(MockProvider { + model_config: mock_model_config, + })) + } + + #[tokio::test] + async fn test_create_read_only_tool() { + let tool = create_read_only_tool(); + assert_eq!(tool.name, "platform__tool_by_tool_permission"); + assert!(tool.description.contains("read-only operation")); + } + + #[test] + fn test_create_check_messages() { + let tool_request = ToolRequest { + id: "tool_1".to_string(), + tool_call: ToolResult::Ok(ToolCall { + name: "file_reader".to_string(), + arguments: json!({"path": "/path/to/file"}), + }), + }; + + let messages = create_check_messages(vec![&tool_request]); + assert_eq!(messages.len(), 1); + let content = &messages[0].content[0]; + if let MessageContent::Text(text_content) = content { + assert!(text_content + .text + .contains("Analyze the tool requests and list the tools")); + assert!(text_content.text.contains("file_reader")); + } else { + panic!("Expected text content"); + } + } + + #[test] + fn test_extract_read_only_tools() { + let message = Message { + role: Role::Assistant, + created: Utc::now().timestamp(), + content: vec![MessageContent::ToolRequest(ToolRequest { + id: "tool_2".to_string(), + tool_call: ToolResult::Ok(ToolCall { + name: "platform__tool_by_tool_permission".to_string(), + arguments: json!({ + "read_only_tools": ["file_reader", "data_fetcher"] + }), + }), + })], + }; + + let result = extract_read_only_tools(&message); + assert!(result.is_some()); + let tools = result.unwrap(); + assert_eq!(tools, vec!["file_reader", "data_fetcher"]); + } + + #[tokio::test] + async fn test_detect_read_only_tools() { + let capabilities = create_mock_capabilities(); + let tool_request = ToolRequest { + id: "tool_1".to_string(), + tool_call: ToolResult::Ok(ToolCall { + name: "file_reader".to_string(), + arguments: json!({"path": "/path/to/file"}), + }), + }; + + let result = detect_read_only_tools(&capabilities, vec![&tool_request]).await; + assert_eq!(result, vec!["file_reader", "data_fetcher"]); + } + + #[tokio::test] + async fn test_detect_read_only_tools_empty_requests() { + let capabilities = create_mock_capabilities(); + let result = detect_read_only_tools(&capabilities, vec![]).await; + assert!(result.is_empty()); + } +} diff --git a/crates/goose/src/agents/truncate.rs b/crates/goose/src/agents/truncate.rs index 86663d2dff..eda6b045d8 100644 --- a/crates/goose/src/agents/truncate.rs +++ b/crates/goose/src/agents/truncate.rs @@ -6,6 +6,7 @@ use tokio::sync::mpsc; use tokio::sync::Mutex; use tracing::{debug, error, instrument, warn}; +use super::detect_read_only_tools; use super::Agent; use crate::agents::capabilities::Capabilities; use crate::agents::extension::{ExtensionConfig, ExtensionResult}; @@ -237,6 +238,8 @@ impl Agent for TruncateAgent { break; } + let read_only_tools = detect_read_only_tools(&capabilities, tool_requests.clone()).await; + // Process tool requests depending on goose_mode let mut message_tool_response = Message::user(); // Clone goose_mode once before the match to avoid move issues @@ -246,31 +249,40 @@ impl Agent for TruncateAgent { // Process each tool request sequentially with confirmation for request in &tool_requests { if let Ok(tool_call) = request.tool_call.clone() { - let confirmation = Message::user().with_tool_confirmation_request( - request.id.clone(), - tool_call.name.clone(), - tool_call.arguments.clone(), - Some("Goose would like to call the tool: {}\nAllow? (y/n): ".to_string()), - ); - yield confirmation; - - // Wait for confirmation response through the channel - let mut rx = self.confirmation_rx.lock().await; - if let Some((req_id, confirmed)) = rx.recv().await { - if req_id == request.id { - if confirmed { - // User approved - dispatch the tool call - let output = capabilities.dispatch_tool_call(tool_call).await; + // Skip confirmation if the tool_call.name is in the read_only_tools list + if read_only_tools.contains(&tool_call.name) { + let output = capabilities.dispatch_tool_call(tool_call).await; message_tool_response = message_tool_response.with_tool_response( request.id.clone(), output, ); - } else { - // User declined - add declined response - message_tool_response = message_tool_response.with_tool_response( - request.id.clone(), - Ok(vec![Content::text("User declined to run this tool.")]), - ); + } else { + let confirmation = Message::user().with_tool_confirmation_request( + request.id.clone(), + tool_call.name.clone(), + tool_call.arguments.clone(), + Some("Goose would like to call the tool: {}\nAllow? (y/n): ".to_string()), + ); + yield confirmation; + + // Wait for confirmation response through the channel + let mut rx = self.confirmation_rx.lock().await; + if let Some((req_id, confirmed)) = rx.recv().await { + if req_id == request.id { + if confirmed { + // User approved - dispatch the tool call + let output = capabilities.dispatch_tool_call(tool_call).await; + message_tool_response = message_tool_response.with_tool_response( + request.id.clone(), + output, + ); + } else { + // User declined - add declined response + message_tool_response = message_tool_response.with_tool_response( + request.id.clone(), + Ok(vec![Content::text("User declined to run this tool.")]), + ); + } } } }