Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/goose/src/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ mod agent;
mod capabilities;
pub mod extension;
mod factory;
mod permission_judge;
mod reference;
mod truncate;

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;
265 changes: 265 additions & 0 deletions crates/goose/src/agents/permission_judge.rs
Original file line number Diff line number Diff line change
@@ -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<Message> {
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<Vec<String>> {
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<String> {
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());
}
}
54 changes: 33 additions & 21 deletions crates/goose/src/agents/truncate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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
Expand All @@ -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.")]),
);
}
}
}
}
Expand Down