Skip to main content

sen_plugin_host/permission/
prompt.rs

1//! Permission prompt handling for user interaction
2//!
3//! Provides trait-based prompt handling that framework users can customize
4//! to match their application's UI requirements.
5
6use sen_plugin_api::Capabilities;
7use std::io::{self, BufRead, Write};
8use thiserror::Error;
9
10use super::store::StoredTrustLevel;
11
12/// Error type for prompt operations
13#[derive(Debug, Error)]
14pub enum PromptError {
15    #[error("Prompt cancelled by user")]
16    Cancelled,
17
18    #[error("Non-interactive environment")]
19    NonInteractive,
20
21    #[error("I/O error: {0}")]
22    IoError(#[from] io::Error),
23
24    #[error("Timeout waiting for user response")]
25    Timeout,
26}
27
28/// Result of a permission prompt
29#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub enum PromptResult {
31    /// Allow this time only
32    AllowOnce,
33    /// Allow for this session
34    AllowSession,
35    /// Allow permanently
36    AllowAlways,
37    /// Deny the permission
38    #[default]
39    Deny,
40}
41
42impl PromptResult {
43    /// Convert to storage trust level (if applicable)
44    pub fn to_trust_level(&self) -> Option<StoredTrustLevel> {
45        match self {
46            Self::AllowOnce => None,
47            Self::AllowSession => Some(StoredTrustLevel::Session),
48            Self::AllowAlways => Some(StoredTrustLevel::Permanent),
49            Self::Deny => None,
50        }
51    }
52
53    /// Check if permission was granted
54    pub fn is_allowed(&self) -> bool {
55        matches!(
56            self,
57            Self::AllowOnce | Self::AllowSession | Self::AllowAlways
58        )
59    }
60
61    /// Check if permission should be persisted
62    pub fn should_persist(&self) -> bool {
63        matches!(self, Self::AllowSession | Self::AllowAlways)
64    }
65}
66
67/// Trait for handling permission prompts
68///
69/// Framework users implement this trait to customize how permission
70/// prompts are displayed and how user input is collected.
71///
72/// # Example
73///
74/// ```rust
75/// use sen_plugin_host::permission::{PromptHandler, PromptResult, PromptError};
76/// use sen_plugin_api::Capabilities;
77///
78/// struct GuiPromptHandler {
79///     // GUI framework handle
80/// }
81///
82/// impl PromptHandler for GuiPromptHandler {
83///     fn prompt(
84///         &self,
85///         plugin: &str,
86///         capabilities: &Capabilities,
87///     ) -> Result<PromptResult, PromptError> {
88///         // Show GUI dialog
89///         // For now, just approve
90///         Ok(PromptResult::AllowOnce)
91///     }
92///
93///     fn is_interactive(&self) -> bool {
94///         true
95///     }
96/// }
97/// ```
98pub trait PromptHandler: Send + Sync {
99    /// Display a permission prompt and get user's decision
100    fn prompt(
101        &self,
102        plugin: &str,
103        capabilities: &Capabilities,
104    ) -> Result<PromptResult, PromptError>;
105
106    /// Check if this handler supports interactive prompts
107    fn is_interactive(&self) -> bool;
108
109    /// Display an escalation warning and get user's decision
110    fn prompt_escalation(
111        &self,
112        plugin: &str,
113        old_caps: &Capabilities,
114        new_caps: &Capabilities,
115    ) -> Result<PromptResult, PromptError> {
116        // Default implementation: treat as new permission request
117        let _ = old_caps;
118        self.prompt(plugin, new_caps)
119    }
120}
121
122// ============================================================================
123// Terminal Prompt Handler
124// ============================================================================
125
126/// Terminal-based prompt handler
127///
128/// Displays permission prompts in the terminal and reads user input.
129#[derive(Debug)]
130pub struct TerminalPromptHandler {
131    /// Whether to show detailed capability information
132    verbose: bool,
133}
134
135impl TerminalPromptHandler {
136    /// Create a new terminal prompt handler
137    pub fn new() -> Self {
138        Self { verbose: true }
139    }
140
141    /// Create a minimal prompt handler (less verbose)
142    pub fn minimal() -> Self {
143        Self { verbose: false }
144    }
145
146    /// Format capabilities for display
147    fn format_capabilities(&self, caps: &Capabilities) -> String {
148        let mut lines = Vec::new();
149
150        if !caps.fs_read.is_empty() {
151            for path in &caps.fs_read {
152                let recursive = if path.recursive { " (recursive)" } else { "" };
153                lines.push(format!("  - Read files in: {}{}", path.pattern, recursive));
154            }
155        }
156
157        if !caps.fs_write.is_empty() {
158            for path in &caps.fs_write {
159                let recursive = if path.recursive { " (recursive)" } else { "" };
160                lines.push(format!("  - Write files in: {}{}", path.pattern, recursive));
161            }
162        }
163
164        if !caps.env_read.is_empty() {
165            let vars = caps.env_read.join(", ");
166            lines.push(format!("  - Read environment: {}", vars));
167        }
168
169        if !caps.net.is_empty() {
170            for net in &caps.net {
171                let port_str = net.port.map(|p| format!(":{}", p)).unwrap_or_default();
172                lines.push(format!("  - Network access: {}{}", net.host, port_str));
173            }
174        }
175
176        if caps.stdio.stdin {
177            lines.push("  - Read from stdin".to_string());
178        }
179        if caps.stdio.stdout {
180            lines.push("  - Write to stdout".to_string());
181        }
182        if caps.stdio.stderr {
183            lines.push("  - Write to stderr".to_string());
184        }
185
186        lines.join("\n")
187    }
188}
189
190impl Default for TerminalPromptHandler {
191    fn default() -> Self {
192        Self::new()
193    }
194}
195
196impl PromptHandler for TerminalPromptHandler {
197    fn prompt(
198        &self,
199        plugin: &str,
200        capabilities: &Capabilities,
201    ) -> Result<PromptResult, PromptError> {
202        let stdin = io::stdin();
203        let mut stdout = io::stdout();
204
205        // Check if we're in an interactive terminal
206        if !atty_check() {
207            return Err(PromptError::NonInteractive);
208        }
209
210        // Display the prompt
211        writeln!(stdout)?;
212        writeln!(
213            stdout,
214            "Plugin \"{}\" requests the following permissions:",
215            plugin
216        )?;
217        writeln!(stdout)?;
218
219        if self.verbose {
220            writeln!(stdout, "{}", self.format_capabilities(capabilities))?;
221            writeln!(stdout)?;
222        }
223
224        write!(stdout, "Allow? [y]es / [n]o / [a]lways / [s]ession: ")?;
225        stdout.flush()?;
226
227        // Read user input
228        let mut input = String::new();
229        stdin.lock().read_line(&mut input)?;
230
231        let input = input.trim().to_lowercase();
232
233        match input.as_str() {
234            "y" | "yes" => Ok(PromptResult::AllowOnce),
235            "n" | "no" => Ok(PromptResult::Deny),
236            "a" | "always" => Ok(PromptResult::AllowAlways),
237            "s" | "session" => Ok(PromptResult::AllowSession),
238            "" => Ok(PromptResult::Deny), // Default to deny
239            _ => {
240                writeln!(stdout, "Invalid input, defaulting to deny")?;
241                Ok(PromptResult::Deny)
242            }
243        }
244    }
245
246    fn is_interactive(&self) -> bool {
247        atty_check()
248    }
249
250    fn prompt_escalation(
251        &self,
252        plugin: &str,
253        old_caps: &Capabilities,
254        new_caps: &Capabilities,
255    ) -> Result<PromptResult, PromptError> {
256        let stdin = io::stdin();
257        let mut stdout = io::stdout();
258
259        if !atty_check() {
260            return Err(PromptError::NonInteractive);
261        }
262
263        writeln!(stdout)?;
264        writeln!(
265            stdout,
266            "WARNING: Plugin \"{}\" requests ADDITIONAL permissions!",
267            plugin
268        )?;
269        writeln!(stdout)?;
270
271        if self.verbose {
272            writeln!(stdout, "Previously granted:")?;
273            writeln!(stdout, "{}", self.format_capabilities(old_caps))?;
274            writeln!(stdout)?;
275            writeln!(stdout, "Now requesting:")?;
276            writeln!(stdout, "{}", self.format_capabilities(new_caps))?;
277            writeln!(stdout)?;
278        }
279
280        write!(stdout, "Allow escalation? [y]es / [n]o / [a]lways: ")?;
281        stdout.flush()?;
282
283        let mut input = String::new();
284        stdin.lock().read_line(&mut input)?;
285
286        let input = input.trim().to_lowercase();
287
288        match input.as_str() {
289            "y" | "yes" => Ok(PromptResult::AllowOnce),
290            "n" | "no" => Ok(PromptResult::Deny),
291            "a" | "always" => Ok(PromptResult::AllowAlways),
292            _ => Ok(PromptResult::Deny),
293        }
294    }
295}
296
297// ============================================================================
298// Auto-Approve Handler (for testing/CI with pre-approved permissions)
299// ============================================================================
300
301/// Handler that automatically approves/denies based on configuration
302#[derive(Debug)]
303pub struct AutoPromptHandler {
304    /// Default response
305    default_response: PromptResult,
306}
307
308impl AutoPromptHandler {
309    /// Create handler that always approves
310    pub fn always_allow() -> Self {
311        Self {
312            default_response: PromptResult::AllowAlways,
313        }
314    }
315
316    /// Create handler that always denies
317    pub fn always_deny() -> Self {
318        Self {
319            default_response: PromptResult::Deny,
320        }
321    }
322
323    /// Create handler with custom default response
324    pub fn with_response(response: PromptResult) -> Self {
325        Self {
326            default_response: response,
327        }
328    }
329}
330
331impl PromptHandler for AutoPromptHandler {
332    fn prompt(
333        &self,
334        _plugin: &str,
335        _capabilities: &Capabilities,
336    ) -> Result<PromptResult, PromptError> {
337        Ok(self.default_response.clone())
338    }
339
340    fn is_interactive(&self) -> bool {
341        false
342    }
343}
344
345// ============================================================================
346// Recording Handler (for testing)
347// ============================================================================
348
349/// Handler that records prompts for testing
350#[derive(Debug, Default)]
351pub struct RecordingPromptHandler {
352    /// Recorded prompts
353    prompts: std::sync::Mutex<Vec<RecordedPrompt>>,
354    /// Response to return
355    response: PromptResult,
356}
357
358/// A recorded prompt
359#[derive(Debug, Clone)]
360pub struct RecordedPrompt {
361    pub plugin: String,
362    pub capabilities_hash: String,
363    pub is_escalation: bool,
364}
365
366impl RecordingPromptHandler {
367    /// Create a new recording handler
368    pub fn new(response: PromptResult) -> Self {
369        Self {
370            prompts: std::sync::Mutex::new(Vec::new()),
371            response,
372        }
373    }
374
375    /// Get all recorded prompts
376    pub fn prompts(&self) -> Vec<RecordedPrompt> {
377        self.prompts
378            .lock()
379            .expect("RecordingPromptHandler mutex poisoned")
380            .clone()
381    }
382
383    /// Get the number of prompts
384    pub fn prompt_count(&self) -> usize {
385        self.prompts
386            .lock()
387            .expect("RecordingPromptHandler mutex poisoned")
388            .len()
389    }
390
391    /// Clear recorded prompts
392    pub fn clear(&self) {
393        self.prompts
394            .lock()
395            .expect("RecordingPromptHandler mutex poisoned")
396            .clear();
397    }
398}
399
400impl PromptHandler for RecordingPromptHandler {
401    fn prompt(
402        &self,
403        plugin: &str,
404        capabilities: &Capabilities,
405    ) -> Result<PromptResult, PromptError> {
406        self.prompts
407            .lock()
408            .expect("RecordingPromptHandler mutex poisoned")
409            .push(RecordedPrompt {
410                plugin: plugin.to_string(),
411                capabilities_hash: capabilities.compute_hash(),
412                is_escalation: false,
413            });
414        Ok(self.response.clone())
415    }
416
417    fn is_interactive(&self) -> bool {
418        false
419    }
420
421    fn prompt_escalation(
422        &self,
423        plugin: &str,
424        _old_caps: &Capabilities,
425        new_caps: &Capabilities,
426    ) -> Result<PromptResult, PromptError> {
427        self.prompts
428            .lock()
429            .expect("RecordingPromptHandler mutex poisoned")
430            .push(RecordedPrompt {
431                plugin: plugin.to_string(),
432                capabilities_hash: new_caps.compute_hash(),
433                is_escalation: true,
434            });
435        Ok(self.response.clone())
436    }
437}
438
439// ============================================================================
440// Helper functions
441// ============================================================================
442
443/// Check if stdin/stdout are connected to a terminal
444fn atty_check() -> bool {
445    // Use platform-specific checks for reliable terminal detection
446    #[cfg(unix)]
447    {
448        use std::os::unix::io::AsRawFd;
449        // Check if stdout is a TTY using libc
450        // SAFETY: isatty is safe to call with any file descriptor
451        unsafe { libc::isatty(std::io::stdout().as_raw_fd()) != 0 }
452    }
453
454    #[cfg(windows)]
455    {
456        use std::os::windows::io::AsRawHandle;
457        // On Windows, check console mode
458        use windows_sys::Win32::System::Console::{GetConsoleMode, CONSOLE_MODE};
459        let handle = std::io::stdout().as_raw_handle();
460        let mut mode: CONSOLE_MODE = 0;
461        // SAFETY: GetConsoleMode is safe with valid handle
462        unsafe { GetConsoleMode(handle as _, &mut mode) != 0 }
463    }
464
465    #[cfg(not(any(unix, windows)))]
466    {
467        // Fallback for other platforms
468        std::env::var("TERM").is_ok()
469    }
470}
471
472#[cfg(test)]
473mod tests {
474    use super::*;
475    use sen_plugin_api::PathPattern;
476
477    #[test]
478    fn test_prompt_result() {
479        assert!(PromptResult::AllowOnce.is_allowed());
480        assert!(PromptResult::AllowAlways.is_allowed());
481        assert!(!PromptResult::Deny.is_allowed());
482
483        assert!(!PromptResult::AllowOnce.should_persist());
484        assert!(PromptResult::AllowAlways.should_persist());
485        assert!(PromptResult::AllowSession.should_persist());
486    }
487
488    #[test]
489    fn test_auto_handler() {
490        let handler = AutoPromptHandler::always_allow();
491        let caps = Capabilities::none();
492
493        let result = handler.prompt("test", &caps).unwrap();
494        assert_eq!(result, PromptResult::AllowAlways);
495
496        let handler = AutoPromptHandler::always_deny();
497        let result = handler.prompt("test", &caps).unwrap();
498        assert_eq!(result, PromptResult::Deny);
499    }
500
501    #[test]
502    fn test_recording_handler() {
503        let handler = RecordingPromptHandler::new(PromptResult::AllowOnce);
504        let caps = Capabilities::default().with_fs_read(vec![PathPattern::new("./data")]);
505
506        handler.prompt("plugin1", &caps).unwrap();
507        handler.prompt("plugin2", &caps).unwrap();
508
509        assert_eq!(handler.prompt_count(), 2);
510        let prompts = handler.prompts();
511        assert_eq!(prompts[0].plugin, "plugin1");
512        assert_eq!(prompts[1].plugin, "plugin2");
513    }
514
515    #[test]
516    fn test_format_capabilities() {
517        let handler = TerminalPromptHandler::new();
518        let caps = Capabilities::default()
519            .with_fs_read(vec![PathPattern::new("./data").recursive()])
520            .with_fs_write(vec![PathPattern::new("./output")])
521            .with_env_read(vec!["HOME".into(), "PATH".into()]);
522
523        let formatted = handler.format_capabilities(&caps);
524        assert!(formatted.contains("./data"));
525        assert!(formatted.contains("recursive"));
526        assert!(formatted.contains("./output"));
527        assert!(formatted.contains("HOME"));
528    }
529}