Skip to content

Commit b4546f6

Browse files
fix: restore smart-approve mode (#7690)
Signed-off-by: Adrian Cole <adrian@tetrate.io>
1 parent 6b6018c commit b4546f6

11 files changed

Lines changed: 327 additions & 179 deletions

File tree

crates/goose-test-support/src/mcp.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ impl McpFixtureServer {
105105
}
106106
}
107107

108-
#[tool(description = "Get the code")]
108+
#[tool(description = "Get the code", annotations(read_only_hint = true))]
109109
fn get_code(&self) -> Result<CallToolResult, McpError> {
110110
Ok(CallToolResult::success(vec![Content::text(FAKE_CODE)]))
111111
}

crates/goose/src/agents/agent.rs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -245,14 +245,18 @@ impl Agent {
245245
tool_result_tx: tool_tx,
246246
tool_result_rx: Arc::new(Mutex::new(tool_rx)),
247247
retry_manager: RetryManager::new(),
248-
tool_inspection_manager: Self::create_tool_inspection_manager(permission_manager),
248+
tool_inspection_manager: Self::create_tool_inspection_manager(
249+
permission_manager,
250+
provider.clone(),
251+
),
249252
container: Mutex::new(None),
250253
}
251254
}
252255

253256
/// Create a tool inspection manager with default inspectors
254257
fn create_tool_inspection_manager(
255258
permission_manager: Arc<PermissionManager>,
259+
provider: SharedProvider,
256260
) -> ToolInspectionManager {
257261
let mut tool_inspection_manager = ToolInspectionManager::new();
258262

@@ -261,9 +265,8 @@ impl Agent {
261265

262266
// Add permission inspector (medium-high priority)
263267
tool_inspection_manager.add_inspector(Box::new(PermissionInspector::new(
264-
std::collections::HashSet::new(), // readonly tools - will be populated from extension manager
265-
std::collections::HashSet::new(), // regular tools - will be populated from extension manager
266268
permission_manager,
269+
provider,
267270
)));
268271

269272
// Add repetition inspector (lower priority - basic repetition checking)
@@ -350,6 +353,10 @@ impl Agent {
350353
.prepare_tools_and_prompt(session_id, working_dir)
351354
.await?;
352355

356+
if self.config.goose_mode == GooseMode::SmartApprove {
357+
self.tool_inspection_manager.apply_tool_annotations(&tools);
358+
}
359+
353360
Ok(ReplyContext {
354361
conversation,
355362
tools,
@@ -1261,6 +1268,7 @@ impl Agent {
12611268
// Run all tool inspectors
12621269
let inspection_results = self.tool_inspection_manager
12631270
.inspect_tools(
1271+
&session_config.id,
12641272
&remaining_requests,
12651273
conversation.messages(),
12661274
goose_mode,

crates/goose/src/config/permission.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
use crate::config::paths::Paths;
2+
use rmcp::model::Tool;
23
use serde::{Deserialize, Serialize};
34
use std::collections::HashMap;
45
use std::fs;
@@ -97,6 +98,51 @@ impl PermissionManager {
9798
self.config_path.as_path()
9899
}
99100

101+
pub fn apply_tool_annotations(&self, tools: &[Tool]) {
102+
let mut write_annotated = Vec::new();
103+
for tool in tools {
104+
let Some(anns) = &tool.annotations else {
105+
continue;
106+
};
107+
if anns.read_only_hint == Some(false) {
108+
write_annotated.push(tool.name.to_string());
109+
}
110+
}
111+
if !write_annotated.is_empty() {
112+
self.bulk_update_smart_approve_permissions(
113+
&write_annotated,
114+
PermissionLevel::AskBefore,
115+
);
116+
}
117+
}
118+
119+
fn bulk_update_smart_approve_permissions(&self, tool_names: &[String], level: PermissionLevel) {
120+
let mut map = self.permission_map.write().unwrap();
121+
let permission_config = map.entry(SMART_APPROVE_PERMISSION.to_string()).or_default();
122+
123+
for tool_name in tool_names {
124+
// Remove from all lists to avoid duplicates
125+
permission_config.always_allow.retain(|p| p != tool_name);
126+
permission_config.ask_before.retain(|p| p != tool_name);
127+
permission_config.never_allow.retain(|p| p != tool_name);
128+
129+
// Add to the appropriate list
130+
match &level {
131+
PermissionLevel::AlwaysAllow => {
132+
permission_config.always_allow.push(tool_name.clone())
133+
}
134+
PermissionLevel::AskBefore => permission_config.ask_before.push(tool_name.clone()),
135+
PermissionLevel::NeverAllow => {
136+
permission_config.never_allow.push(tool_name.clone())
137+
}
138+
}
139+
}
140+
141+
let yaml_content =
142+
serde_yaml::to_string(&*map).expect("Failed to serialize permission config");
143+
fs::write(&self.config_path, yaml_content).expect("Failed to write to permission.yaml");
144+
}
145+
100146
/// Helper function to retrieve the permission level for a specific permission category and tool.
101147
fn get_permission(&self, name: &str, principal_name: &str) -> Option<PermissionLevel> {
102148
let map = self.permission_map.read().unwrap();
@@ -191,6 +237,8 @@ impl PermissionManager {
191237
#[cfg(test)]
192238
mod tests {
193239
use super::*;
240+
use rmcp::model::ToolAnnotations;
241+
use rmcp::object;
194242
use tempfile::TempDir;
195243

196244
// Helper function to create a test instance of PermissionManager with a temp dir
@@ -313,4 +361,29 @@ mod tests {
313361
fs::write(&permission_path, "{{invalid yaml: [broken").unwrap();
314362
PermissionManager::new(temp_dir.path().to_path_buf());
315363
}
364+
365+
use test_case::test_case;
366+
367+
#[test_case(
368+
vec![Tool::new("tool".to_string(), String::new(), object!({"type": "object"}))
369+
.annotate(ToolAnnotations::new().read_only(false))],
370+
Some(PermissionLevel::AskBefore);
371+
"write_annotation_caches_ask"
372+
)]
373+
#[test_case(
374+
vec![Tool::new("tool".to_string(), String::new(), object!({"type": "object"}))],
375+
None;
376+
"unannotated_left_uncached"
377+
)]
378+
#[test_case(
379+
vec![Tool::new("tool".to_string(), String::new(), object!({"type": "object"}))
380+
.annotate(ToolAnnotations::new().read_only(true))],
381+
None;
382+
"readonly_annotation_skipped"
383+
)]
384+
fn test_apply_tool_annotations(tools: Vec<Tool>, expect_cache: Option<PermissionLevel>) {
385+
let (manager, _temp_dir) = create_test_permission_manager();
386+
manager.apply_tool_annotations(&tools);
387+
assert_eq!(manager.get_smart_approve_permission("tool"), expect_cache);
388+
}
316389
}

crates/goose/src/permission/mod.rs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,4 @@ pub mod permission_store;
55

66
pub use permission_confirmation::{Permission, PermissionConfirmation};
77
pub use permission_inspector::PermissionInspector;
8-
pub use permission_judge::detect_read_only_tools;
98
pub use permission_store::ToolPermissionStore;

crates/goose/src/permission/permission_inspector.rs

Lines changed: 144 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,52 @@
11
use crate::agents::platform_extensions::MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE;
2+
use crate::agents::types::SharedProvider;
23
use crate::config::permission::PermissionLevel;
34
use crate::config::{GooseMode, PermissionManager};
45
use crate::conversation::message::{Message, ToolRequest};
5-
use crate::permission::permission_judge::PermissionCheckResult;
6+
use crate::permission::permission_judge::{detect_read_only_tools, PermissionCheckResult};
67
use crate::tool_inspection::{InspectionAction, InspectionResult, ToolInspector};
78
use anyhow::Result;
89
use async_trait::async_trait;
10+
use rmcp::model::Tool;
911
use std::collections::HashSet;
10-
use std::sync::Arc;
12+
use std::sync::{Arc, RwLock};
1113

1214
/// Permission Inspector that handles tool permission checking
1315
pub struct PermissionInspector {
14-
readonly_tools: HashSet<String>,
15-
regular_tools: HashSet<String>,
1616
pub permission_manager: Arc<PermissionManager>,
17+
provider: SharedProvider,
18+
readonly_tools: RwLock<HashSet<String>>,
1719
}
1820

1921
impl PermissionInspector {
20-
pub fn new(
21-
readonly_tools: HashSet<String>,
22-
regular_tools: HashSet<String>,
23-
permission_manager: Arc<PermissionManager>,
24-
) -> Self {
22+
pub fn new(permission_manager: Arc<PermissionManager>, provider: SharedProvider) -> Self {
2523
Self {
26-
readonly_tools,
27-
regular_tools,
2824
permission_manager,
25+
provider,
26+
readonly_tools: RwLock::new(HashSet::new()),
2927
}
3028
}
3129

30+
// readonly_tools is per-agent to avoid concurrent session clobbering; write-annotated
31+
// tools are cached globally via PermissionManager.
32+
pub fn apply_tool_annotations(&self, tools: &[Tool]) {
33+
let mut readonly_annotated = HashSet::new();
34+
for tool in tools {
35+
let Some(anns) = &tool.annotations else {
36+
continue;
37+
};
38+
if anns.read_only_hint == Some(true) {
39+
readonly_annotated.insert(tool.name.to_string());
40+
}
41+
}
42+
*self.readonly_tools.write().unwrap() = readonly_annotated;
43+
self.permission_manager.apply_tool_annotations(tools);
44+
}
45+
46+
pub fn is_readonly_annotated_tool(&self, tool_name: &str) -> bool {
47+
self.readonly_tools.read().unwrap().contains(tool_name)
48+
}
49+
3250
/// Process inspection results into permission decisions
3351
/// This method takes all inspection results and converts them into a PermissionCheckResult
3452
/// that can be used by the agent to determine which tools to approve, deny, or ask for approval
@@ -105,12 +123,14 @@ impl ToolInspector for PermissionInspector {
105123

106124
async fn inspect(
107125
&self,
126+
session_id: &str,
108127
tool_requests: &[ToolRequest],
109128
_messages: &[Message],
110129
goose_mode: GooseMode,
111130
) -> Result<Vec<InspectionResult>> {
112131
let mut results = Vec::new();
113132
let permission_manager = &self.permission_manager;
133+
let mut llm_detect_candidates: Vec<&ToolRequest> = Vec::new();
114134

115135
for request in tool_requests {
116136
if let Ok(tool_call) = &request.tool_call {
@@ -129,21 +149,28 @@ impl ToolInspector for PermissionInspector {
129149
InspectionAction::RequireApproval(None)
130150
}
131151
}
132-
}
133-
// 2. Check if it's a readonly or regular tool (both pre-approved)
134-
else if self.readonly_tools.contains(&**tool_name)
135-
|| self.regular_tools.contains(&**tool_name)
152+
// 2. Check if it's a smart-approved tool (annotation or cached LLM decision)
153+
} else if self.is_readonly_annotated_tool(tool_name)
154+
|| (goose_mode == GooseMode::SmartApprove
155+
&& permission_manager.get_smart_approve_permission(tool_name)
156+
== Some(PermissionLevel::AlwaysAllow))
136157
{
137158
InspectionAction::Allow
138-
}
139-
// 4. Special case for extension management
140-
else if tool_name == MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE {
159+
// 3. Special case for extension management
160+
} else if tool_name == MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE {
141161
InspectionAction::RequireApproval(Some(
142162
"Extension management requires approval for security".to_string(),
143163
))
144-
}
164+
// 4. Defer to LLM detection (SmartApprove, not yet cached)
165+
} else if goose_mode == GooseMode::SmartApprove
166+
&& permission_manager
167+
.get_smart_approve_permission(tool_name)
168+
.is_none()
169+
{
170+
llm_detect_candidates.push(request);
171+
continue;
145172
// 5. Default: require approval for unknown tools
146-
else {
173+
} else {
147174
InspectionAction::RequireApproval(None)
148175
}
149176
}
@@ -153,10 +180,10 @@ impl ToolInspector for PermissionInspector {
153180
InspectionAction::Allow => {
154181
if goose_mode == GooseMode::Auto {
155182
"Auto mode - all tools approved".to_string()
156-
} else if self.readonly_tools.contains(&**tool_name) {
157-
"Tool marked as read-only".to_string()
158-
} else if self.regular_tools.contains(&**tool_name) {
159-
"Tool pre-approved".to_string()
183+
} else if self.is_readonly_annotated_tool(tool_name) {
184+
"Tool annotated as read-only".to_string()
185+
} else if goose_mode == GooseMode::SmartApprove {
186+
"SmartApprove cached as read-only".to_string()
160187
} else {
161188
"User permission allows this tool".to_string()
162189
}
@@ -182,6 +209,99 @@ impl ToolInspector for PermissionInspector {
182209
}
183210
}
184211

212+
// LLM-based read-only detection for deferred SmartApprove candidates
213+
if !llm_detect_candidates.is_empty() {
214+
let detected: HashSet<String> = match self.provider.lock().await.clone() {
215+
Some(provider) => {
216+
detect_read_only_tools(provider, session_id, llm_detect_candidates.to_vec())
217+
.await
218+
.into_iter()
219+
.collect()
220+
}
221+
None => Default::default(),
222+
};
223+
224+
for candidate in &llm_detect_candidates {
225+
let is_readonly = candidate
226+
.tool_call
227+
.as_ref()
228+
.map(|tc| detected.contains(&tc.name.to_string()))
229+
.unwrap_or(false);
230+
231+
// Cache the LLM decision for future calls
232+
if let Ok(tc) = &candidate.tool_call {
233+
let level = if is_readonly {
234+
PermissionLevel::AlwaysAllow
235+
} else {
236+
PermissionLevel::AskBefore
237+
};
238+
permission_manager.update_smart_approve_permission(&tc.name, level);
239+
}
240+
241+
results.push(InspectionResult {
242+
tool_request_id: candidate.id.clone(),
243+
action: if is_readonly {
244+
InspectionAction::Allow
245+
} else {
246+
InspectionAction::RequireApproval(None)
247+
},
248+
reason: if is_readonly {
249+
"LLM detected as read-only".to_string()
250+
} else {
251+
"Tool requires user approval".to_string()
252+
},
253+
confidence: 1.0, // Permission decisions are definitive
254+
inspector_name: self.name().to_string(),
255+
finding_id: None,
256+
});
257+
}
258+
}
259+
185260
Ok(results)
186261
}
187262
}
263+
264+
#[cfg(test)]
265+
mod tests {
266+
use super::*;
267+
use rmcp::model::CallToolRequestParams;
268+
use rmcp::object;
269+
use std::sync::Arc;
270+
use test_case::test_case;
271+
use tokio::sync::Mutex;
272+
273+
#[test_case(GooseMode::Auto, false, None, InspectionAction::Allow; "auto_allows")]
274+
#[test_case(GooseMode::SmartApprove, true, None, InspectionAction::Allow; "smart_approve_annotation_allows")]
275+
#[test_case(GooseMode::SmartApprove, false, Some(PermissionLevel::AlwaysAllow), InspectionAction::Allow; "smart_approve_cached_allow")]
276+
#[test_case(GooseMode::SmartApprove, false, Some(PermissionLevel::AskBefore), InspectionAction::RequireApproval(None); "smart_approve_cached_ask")]
277+
#[test_case(GooseMode::SmartApprove, false, None, InspectionAction::RequireApproval(None); "smart_approve_unknown_defers")]
278+
#[test_case(GooseMode::Approve, false, None, InspectionAction::RequireApproval(None); "approve_requires_approval")]
279+
#[test_case(GooseMode::Approve, false, Some(PermissionLevel::AlwaysAllow), InspectionAction::RequireApproval(None); "approve_ignores_cache")]
280+
#[tokio::test]
281+
async fn test_inspect_action(
282+
mode: GooseMode,
283+
smart_approved: bool,
284+
cache: Option<PermissionLevel>,
285+
expected: InspectionAction,
286+
) {
287+
let pm = Arc::new(PermissionManager::new(tempfile::tempdir().unwrap().keep()));
288+
if let Some(level) = cache {
289+
pm.update_smart_approve_permission("tool", level);
290+
}
291+
let inspector = PermissionInspector::new(pm, Arc::new(Mutex::new(None)));
292+
if smart_approved {
293+
*inspector.readonly_tools.write().unwrap() = ["tool".to_string()].into_iter().collect();
294+
}
295+
let req = ToolRequest {
296+
id: "req".into(),
297+
tool_call: Ok(CallToolRequestParams::new("tool").with_arguments(object!({}))),
298+
metadata: None,
299+
tool_meta: None,
300+
};
301+
let results = inspector
302+
.inspect(goose_test_support::TEST_SESSION_ID, &[req], &[], mode)
303+
.await
304+
.unwrap();
305+
assert_eq!(results[0].action, expected);
306+
}
307+
}

0 commit comments

Comments
 (0)