gestura_core_tools/
tool_inspection.rs

1//! Tool Inspection Manager
2//!
3//! Provides unified tool inspection, permission checking, and confirmation flow.
4//! Integrates execution mode permissions with persistent permission storage.
5//! Based on Block Goose's ToolInspectionManager pattern.
6
7use crate::error::AppError;
8use crate::permissions::{PermissionManager, PermissionScope};
9use gestura_core_foundation::execution_mode::{
10    ExecutionMode, ModeManager, ToolCategory, ToolExecutionCheck,
11};
12use serde::{Deserialize, Serialize};
13use std::collections::HashMap;
14use std::sync::{Arc, RwLock};
15
16/// Tool metadata for inspection
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ToolMetadata {
19    /// Tool name
20    pub name: String,
21    /// Human-readable description
22    pub description: String,
23    /// Tool category for permission grouping
24    pub category: ToolCategory,
25    /// Whether this tool has side effects
26    pub has_side_effects: bool,
27    /// Risk level (0-10, higher = more dangerous)
28    pub risk_level: u8,
29    /// Required capabilities (e.g., "filesystem", "network")
30    pub required_capabilities: Vec<String>,
31}
32
33impl ToolMetadata {
34    /// Create metadata for a read-only tool
35    pub fn read_only(name: impl Into<String>, description: impl Into<String>) -> Self {
36        Self {
37            name: name.into(),
38            description: description.into(),
39            category: ToolCategory::ReadOnly,
40            has_side_effects: false,
41            risk_level: 0,
42            required_capabilities: vec![],
43        }
44    }
45
46    /// Create metadata for a write tool
47    pub fn write(name: impl Into<String>, description: impl Into<String>) -> Self {
48        Self {
49            name: name.into(),
50            description: description.into(),
51            category: ToolCategory::Write,
52            has_side_effects: true,
53            risk_level: 3,
54            required_capabilities: vec!["filesystem".to_string()],
55        }
56    }
57
58    /// Create metadata for a shell tool
59    pub fn shell(name: impl Into<String>, description: impl Into<String>) -> Self {
60        Self {
61            name: name.into(),
62            description: description.into(),
63            category: ToolCategory::Shell,
64            has_side_effects: true,
65            risk_level: 7,
66            required_capabilities: vec!["shell".to_string()],
67        }
68    }
69
70    /// Create metadata for a network tool
71    pub fn network(name: impl Into<String>, description: impl Into<String>) -> Self {
72        Self {
73            name: name.into(),
74            description: description.into(),
75            category: ToolCategory::Network,
76            has_side_effects: false,
77            risk_level: 2,
78            required_capabilities: vec!["network".to_string()],
79        }
80    }
81
82    /// Create metadata for a git tool
83    pub fn git(name: impl Into<String>, description: impl Into<String>) -> Self {
84        Self {
85            name: name.into(),
86            description: description.into(),
87            category: ToolCategory::Git,
88            has_side_effects: true,
89            risk_level: 5,
90            required_capabilities: vec!["git".to_string()],
91        }
92    }
93}
94
95/// Result of tool inspection
96#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct InspectionResult {
98    /// Tool name
99    pub tool_name: String,
100    /// Whether execution is allowed
101    pub allowed: bool,
102    /// Whether confirmation is required
103    pub requires_confirmation: bool,
104    /// Reason for the decision
105    pub reason: String,
106    /// Tool metadata if available
107    pub metadata: Option<ToolMetadata>,
108    /// Suggested confirmation message
109    pub confirmation_message: Option<String>,
110}
111
112impl InspectionResult {
113    /// Create an allowed result
114    pub fn allowed(tool_name: impl Into<String>) -> Self {
115        Self {
116            tool_name: tool_name.into(),
117            allowed: true,
118            requires_confirmation: false,
119            reason: "Tool execution allowed".to_string(),
120            metadata: None,
121            confirmation_message: None,
122        }
123    }
124
125    /// Create a result requiring confirmation
126    pub fn needs_confirmation(tool_name: impl Into<String>, message: impl Into<String>) -> Self {
127        let name = tool_name.into();
128        Self {
129            tool_name: name.clone(),
130            allowed: true,
131            requires_confirmation: true,
132            reason: "Tool requires user confirmation".to_string(),
133            metadata: None,
134            confirmation_message: Some(message.into()),
135        }
136    }
137
138    /// Create a blocked result
139    pub fn blocked(tool_name: impl Into<String>, reason: impl Into<String>) -> Self {
140        Self {
141            tool_name: tool_name.into(),
142            allowed: false,
143            requires_confirmation: false,
144            reason: reason.into(),
145            metadata: None,
146            confirmation_message: None,
147        }
148    }
149
150    /// Add metadata to the result
151    pub fn with_metadata(mut self, metadata: ToolMetadata) -> Self {
152        self.metadata = Some(metadata);
153        self
154    }
155}
156
157/// Confirmation request for user approval
158#[derive(Debug, Clone, Serialize, Deserialize)]
159pub struct ConfirmationRequest {
160    /// Unique request ID
161    pub id: String,
162    /// Tool name
163    pub tool_name: String,
164    /// Tool arguments (for display)
165    pub arguments: String,
166    /// Human-readable description of what will happen
167    pub description: String,
168    /// Risk level (0-10)
169    pub risk_level: u8,
170    /// Whether to remember this decision
171    pub remember_decision: bool,
172}
173
174/// User's response to a confirmation request
175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
176pub enum ConfirmationResponse {
177    /// Allow this execution
178    Allow,
179    /// Allow and remember for this session
180    AllowSession,
181    /// Allow and remember permanently
182    AllowAlways,
183    /// Deny this execution
184    Deny,
185    /// Deny and block for this session
186    DenySession,
187}
188
189/// Tool Inspection Manager
190///
191/// Provides unified tool inspection, permission checking, and confirmation flow.
192/// Integrates:
193/// - `ModeManager` for execution mode-based permissions
194/// - `PermissionManager` for persistent permission storage
195/// - Tool metadata registry for categorization
196pub struct ToolInspectionManager {
197    /// Mode manager for execution mode permissions
198    mode_manager: Arc<RwLock<ModeManager>>,
199    /// Permission manager for persistent permissions
200    permission_manager: Arc<PermissionManager>,
201    /// Tool metadata registry
202    tool_registry: RwLock<HashMap<String, ToolMetadata>>,
203    /// Pending confirmation requests
204    pending_confirmations: RwLock<HashMap<String, ConfirmationRequest>>,
205}
206
207impl Default for ToolInspectionManager {
208    fn default() -> Self {
209        Self::new()
210    }
211}
212
213impl ToolInspectionManager {
214    /// Create a new tool inspection manager
215    pub fn new() -> Self {
216        let manager = Self {
217            mode_manager: Arc::new(RwLock::new(ModeManager::new())),
218            permission_manager: Arc::new(PermissionManager::new()),
219            tool_registry: RwLock::new(HashMap::new()),
220            pending_confirmations: RwLock::new(HashMap::new()),
221        };
222        manager.register_builtin_tools();
223        manager
224    }
225
226    /// Create with custom mode manager
227    pub fn with_mode_manager(mode_manager: ModeManager) -> Self {
228        let manager = Self {
229            mode_manager: Arc::new(RwLock::new(mode_manager)),
230            permission_manager: Arc::new(PermissionManager::new()),
231            tool_registry: RwLock::new(HashMap::new()),
232            pending_confirmations: RwLock::new(HashMap::new()),
233        };
234        manager.register_builtin_tools();
235        manager
236    }
237
238    /// Register built-in tool metadata
239    fn register_builtin_tools(&self) {
240        let tools = vec![
241            ToolMetadata::read_only("read_file", "Read contents of a file"),
242            ToolMetadata::read_only("list_directory", "List files in a directory"),
243            ToolMetadata::read_only("search_files", "Search for files by pattern"),
244            ToolMetadata::write("write_file", "Write content to a file"),
245            ToolMetadata::write("edit_file", "Apply an exact replacement inside a file"),
246            ToolMetadata::write("create_file", "Create a new file"),
247            ToolMetadata::write("delete_file", "Delete a file"),
248            ToolMetadata::shell("shell", "Execute a shell command"),
249            ToolMetadata::shell("bash", "Execute a bash command"),
250            ToolMetadata::shell("execute", "Execute a command"),
251            ToolMetadata::network("web_search", "Search the web"),
252            ToolMetadata::network("web_fetch", "Fetch a web page"),
253            ToolMetadata::git("git", "Execute git commands"),
254            ToolMetadata::git("git_status", "Get git repository status"),
255            ToolMetadata::git("git_commit", "Create a git commit"),
256            ToolMetadata::git("git_push", "Push to remote repository"),
257        ];
258
259        if let Ok(mut registry) = self.tool_registry.write() {
260            for tool in tools {
261                registry.insert(tool.name.clone(), tool);
262            }
263        }
264    }
265
266    /// Register a custom tool
267    pub fn register_tool(&self, metadata: ToolMetadata) {
268        if let Ok(mut registry) = self.tool_registry.write() {
269            registry.insert(metadata.name.clone(), metadata);
270        }
271    }
272
273    /// Get tool metadata
274    pub fn get_tool_metadata(&self, tool_name: &str) -> Option<ToolMetadata> {
275        self.tool_registry
276            .read()
277            .ok()
278            .and_then(|r| r.get(tool_name).cloned())
279    }
280
281    /// Get the current execution mode
282    pub fn current_mode(&self) -> ExecutionMode {
283        self.mode_manager
284            .read()
285            .map(|m| m.mode())
286            .unwrap_or_default()
287    }
288
289    /// Set the execution mode
290    pub fn set_mode(&self, mode: ExecutionMode) {
291        if let Ok(mut manager) = self.mode_manager.write() {
292            manager.set_mode(mode);
293        }
294    }
295
296    /// Inspect a tool before execution
297    ///
298    /// Returns an `InspectionResult` indicating whether the tool can be executed,
299    /// requires confirmation, or is blocked.
300    pub fn inspect_tool(
301        &self,
302        tool_name: &str,
303        arguments: Option<&str>,
304    ) -> Result<InspectionResult, AppError> {
305        // Get tool metadata
306        let metadata = self.get_tool_metadata(tool_name);
307        let category = metadata
308            .as_ref()
309            .map(|m| m.category)
310            .unwrap_or(ToolCategory::Shell); // Default to Shell (most restrictive)
311
312        // Check mode-based permissions
313        let mode_check = self
314            .mode_manager
315            .read()
316            .map_err(|e| AppError::Io(std::io::Error::other(format!("Lock error: {e}"))))?
317            .can_execute_tool(tool_name, category);
318
319        match mode_check {
320            ToolExecutionCheck::Allowed => {
321                // Also check persistent permissions
322                let perm_check = self
323                    .permission_manager
324                    .check(tool_name, "execute", arguments)?;
325                if perm_check.allowed {
326                    Ok(InspectionResult::allowed(tool_name))
327                } else {
328                    // Mode allows but no persistent permission - still allowed
329                    Ok(InspectionResult::allowed(tool_name))
330                }
331            }
332            ToolExecutionCheck::RequiresConfirmation => {
333                // Check if we have a persistent permission that allows it
334                let perm_check = self
335                    .permission_manager
336                    .check(tool_name, "execute", arguments)?;
337                if perm_check.allowed {
338                    Ok(InspectionResult::allowed(tool_name))
339                } else {
340                    let message = self.build_confirmation_message(tool_name, arguments, &metadata);
341                    let mut result = InspectionResult::needs_confirmation(tool_name, message);
342                    if let Some(meta) = metadata {
343                        result = result.with_metadata(meta);
344                    }
345                    Ok(result)
346                }
347            }
348            ToolExecutionCheck::Blocked { reason } => {
349                let mut result = InspectionResult::blocked(tool_name, reason);
350                if let Some(meta) = metadata {
351                    result = result.with_metadata(meta);
352                }
353                Ok(result)
354            }
355        }
356    }
357
358    /// Build a confirmation message for the user
359    fn build_confirmation_message(
360        &self,
361        tool_name: &str,
362        arguments: Option<&str>,
363        metadata: &Option<ToolMetadata>,
364    ) -> String {
365        let desc = metadata
366            .as_ref()
367            .map(|m| m.description.as_str())
368            .unwrap_or("Execute tool");
369        let args_preview = arguments
370            .map(|a| {
371                if a.len() > 100 {
372                    format!("{}...", &a[..100])
373                } else {
374                    a.to_string()
375                }
376            })
377            .unwrap_or_default();
378
379        format!(
380            "Allow '{}' to {}?\n\nArguments: {}",
381            tool_name, desc, args_preview
382        )
383    }
384
385    /// Create a confirmation request
386    pub fn create_confirmation_request(
387        &self,
388        tool_name: &str,
389        arguments: &str,
390    ) -> ConfirmationRequest {
391        let metadata = self.get_tool_metadata(tool_name);
392        let id = uuid::Uuid::new_v4().to_string();
393        let description = self.build_confirmation_message(tool_name, Some(arguments), &metadata);
394        let risk_level = metadata.as_ref().map(|m| m.risk_level).unwrap_or(5);
395
396        let request = ConfirmationRequest {
397            id: id.clone(),
398            tool_name: tool_name.to_string(),
399            arguments: arguments.to_string(),
400            description,
401            risk_level,
402            remember_decision: false,
403        };
404
405        // Store pending request
406        if let Ok(mut pending) = self.pending_confirmations.write() {
407            pending.insert(id, request.clone());
408        }
409
410        request
411    }
412
413    /// Handle a confirmation response
414    pub fn handle_confirmation(
415        &self,
416        request_id: &str,
417        response: ConfirmationResponse,
418    ) -> Result<bool, AppError> {
419        // Get and remove the pending request
420        let request = self
421            .pending_confirmations
422            .write()
423            .ok()
424            .and_then(|mut p| p.remove(request_id));
425
426        let Some(request) = request else {
427            return Err(AppError::Io(std::io::Error::other(format!(
428                "No pending confirmation with id: {}",
429                request_id
430            ))));
431        };
432
433        match response {
434            ConfirmationResponse::Allow => {
435                // One-time allow, no persistence
436                Ok(true)
437            }
438            ConfirmationResponse::AllowSession => {
439                // Remember for this session
440                if let Ok(mut manager) = self.mode_manager.write() {
441                    manager.confirm_tool(&request.tool_name);
442                }
443                Ok(true)
444            }
445            ConfirmationResponse::AllowAlways => {
446                // Persist permission
447                self.permission_manager.grant(
448                    &request.tool_name,
449                    "execute",
450                    PermissionScope::Global,
451                    None,
452                )?;
453                Ok(true)
454            }
455            ConfirmationResponse::Deny => {
456                // One-time deny
457                Ok(false)
458            }
459            ConfirmationResponse::DenySession => {
460                // Block for this session
461                if let Ok(mut manager) = self.mode_manager.write() {
462                    manager.block_tool_for_session(&request.tool_name);
463                }
464                Ok(false)
465            }
466        }
467    }
468
469    /// Get pending confirmation requests
470    pub fn pending_requests(&self) -> Vec<ConfirmationRequest> {
471        self.pending_confirmations
472            .read()
473            .map(|p| p.values().cloned().collect())
474            .unwrap_or_default()
475    }
476
477    /// Clear all pending confirmations
478    pub fn clear_pending(&self) {
479        if let Ok(mut pending) = self.pending_confirmations.write() {
480            pending.clear();
481        }
482    }
483
484    /// List all registered tools
485    pub fn list_tools(&self) -> Vec<ToolMetadata> {
486        self.tool_registry
487            .read()
488            .map(|r| r.values().cloned().collect())
489            .unwrap_or_default()
490    }
491
492    /// Get tools by category
493    pub fn tools_by_category(&self, category: ToolCategory) -> Vec<ToolMetadata> {
494        self.tool_registry
495            .read()
496            .map(|r| {
497                r.values()
498                    .filter(|t| t.category == category)
499                    .cloned()
500                    .collect()
501            })
502            .unwrap_or_default()
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    use super::*;
509
510    #[test]
511    fn test_tool_metadata_factories() {
512        let read = ToolMetadata::read_only("test", "Test tool");
513        assert_eq!(read.category, ToolCategory::ReadOnly);
514        assert!(!read.has_side_effects);
515        assert_eq!(read.risk_level, 0);
516
517        let shell = ToolMetadata::shell("bash", "Bash shell");
518        assert_eq!(shell.category, ToolCategory::Shell);
519        assert!(shell.has_side_effects);
520        assert_eq!(shell.risk_level, 7);
521    }
522
523    #[test]
524    fn test_inspection_result_factories() {
525        let allowed = InspectionResult::allowed("test");
526        assert!(allowed.allowed);
527        assert!(!allowed.requires_confirmation);
528
529        let needs_confirm = InspectionResult::needs_confirmation("test", "Please confirm");
530        assert!(needs_confirm.allowed);
531        assert!(needs_confirm.requires_confirmation);
532
533        let blocked = InspectionResult::blocked("test", "Not allowed");
534        assert!(!blocked.allowed);
535        assert!(!blocked.requires_confirmation);
536    }
537
538    #[test]
539    fn test_tool_inspection_manager_creation() {
540        let manager = ToolInspectionManager::new();
541        assert_eq!(manager.current_mode(), ExecutionMode::Agent);
542
543        // Should have built-in tools registered
544        let tools = manager.list_tools();
545        assert!(!tools.is_empty());
546        assert!(manager.get_tool_metadata("read_file").is_some());
547        assert!(manager.get_tool_metadata("shell").is_some());
548    }
549
550    #[test]
551    fn test_inspect_read_only_tool() {
552        let manager = ToolInspectionManager::new();
553
554        // Read-only tools should be allowed in all modes
555        let result = manager.inspect_tool("read_file", None).unwrap();
556        assert!(result.allowed);
557        assert!(!result.requires_confirmation);
558    }
559
560    #[test]
561    fn test_inspect_shell_tool_agent_mode() {
562        let manager = ToolInspectionManager::new();
563
564        // Shell tools require confirmation in Agent mode
565        let result = manager.inspect_tool("shell", Some("ls -la")).unwrap();
566        assert!(result.allowed);
567        assert!(result.requires_confirmation);
568    }
569
570    #[test]
571    fn test_inspect_shell_tool_auto_mode() {
572        let manager = ToolInspectionManager::new();
573        manager.set_mode(ExecutionMode::Auto);
574
575        // Shell tools are allowed without confirmation in Auto mode
576        let result = manager.inspect_tool("shell", Some("ls -la")).unwrap();
577        assert!(result.allowed);
578        assert!(!result.requires_confirmation);
579    }
580
581    #[test]
582    fn test_inspect_shell_tool_restricted_mode() {
583        let manager = ToolInspectionManager::new();
584        manager.set_mode(ExecutionMode::Restricted);
585
586        // Shell tools are blocked in Restricted mode
587        let result = manager.inspect_tool("shell", Some("ls -la")).unwrap();
588        assert!(!result.allowed);
589    }
590
591    #[test]
592    fn test_confirmation_flow() {
593        let manager = ToolInspectionManager::new();
594
595        // Create confirmation request
596        let request = manager.create_confirmation_request("shell", "rm -rf /tmp/test");
597        assert!(!request.id.is_empty());
598        assert_eq!(request.tool_name, "shell");
599
600        // Should be in pending
601        assert_eq!(manager.pending_requests().len(), 1);
602
603        // Handle confirmation
604        let allowed = manager
605            .handle_confirmation(&request.id, ConfirmationResponse::AllowSession)
606            .unwrap();
607        assert!(allowed);
608
609        // Should be removed from pending
610        assert!(manager.pending_requests().is_empty());
611
612        // Tool should now be allowed without confirmation
613        let result = manager.inspect_tool("shell", None).unwrap();
614        assert!(result.allowed);
615        assert!(!result.requires_confirmation);
616    }
617
618    #[test]
619    fn test_tools_by_category() {
620        let manager = ToolInspectionManager::new();
621
622        let read_tools = manager.tools_by_category(ToolCategory::ReadOnly);
623        assert!(!read_tools.is_empty());
624        assert!(
625            read_tools
626                .iter()
627                .all(|t| t.category == ToolCategory::ReadOnly)
628        );
629
630        let shell_tools = manager.tools_by_category(ToolCategory::Shell);
631        assert!(!shell_tools.is_empty());
632        assert!(
633            shell_tools
634                .iter()
635                .all(|t| t.category == ToolCategory::Shell)
636        );
637    }
638}