11use crate :: agents:: platform_extensions:: MANAGE_EXTENSIONS_TOOL_NAME_COMPLETE ;
2+ use crate :: agents:: types:: SharedProvider ;
23use crate :: config:: permission:: PermissionLevel ;
34use crate :: config:: { GooseMode , PermissionManager } ;
45use crate :: conversation:: message:: { Message , ToolRequest } ;
5- use crate :: permission:: permission_judge:: PermissionCheckResult ;
6+ use crate :: permission:: permission_judge:: { detect_read_only_tools , PermissionCheckResult } ;
67use crate :: tool_inspection:: { InspectionAction , InspectionResult , ToolInspector } ;
78use anyhow:: Result ;
89use async_trait:: async_trait;
10+ use rmcp:: model:: Tool ;
911use std:: collections:: HashSet ;
10- use std:: sync:: Arc ;
12+ use std:: sync:: { Arc , RwLock } ;
1113
1214/// Permission Inspector that handles tool permission checking
1315pub 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
1921impl 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