sen_plugin_host/permission/
prompt.rs1use sen_plugin_api::Capabilities;
7use std::io::{self, BufRead, Write};
8use thiserror::Error;
9
10use super::store::StoredTrustLevel;
11
12#[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#[derive(Debug, Clone, PartialEq, Eq, Default)]
30pub enum PromptResult {
31 AllowOnce,
33 AllowSession,
35 AllowAlways,
37 #[default]
39 Deny,
40}
41
42impl PromptResult {
43 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 pub fn is_allowed(&self) -> bool {
55 matches!(
56 self,
57 Self::AllowOnce | Self::AllowSession | Self::AllowAlways
58 )
59 }
60
61 pub fn should_persist(&self) -> bool {
63 matches!(self, Self::AllowSession | Self::AllowAlways)
64 }
65}
66
67pub trait PromptHandler: Send + Sync {
99 fn prompt(
101 &self,
102 plugin: &str,
103 capabilities: &Capabilities,
104 ) -> Result<PromptResult, PromptError>;
105
106 fn is_interactive(&self) -> bool;
108
109 fn prompt_escalation(
111 &self,
112 plugin: &str,
113 old_caps: &Capabilities,
114 new_caps: &Capabilities,
115 ) -> Result<PromptResult, PromptError> {
116 let _ = old_caps;
118 self.prompt(plugin, new_caps)
119 }
120}
121
122#[derive(Debug)]
130pub struct TerminalPromptHandler {
131 verbose: bool,
133}
134
135impl TerminalPromptHandler {
136 pub fn new() -> Self {
138 Self { verbose: true }
139 }
140
141 pub fn minimal() -> Self {
143 Self { verbose: false }
144 }
145
146 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 if !atty_check() {
207 return Err(PromptError::NonInteractive);
208 }
209
210 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 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), _ => {
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#[derive(Debug)]
303pub struct AutoPromptHandler {
304 default_response: PromptResult,
306}
307
308impl AutoPromptHandler {
309 pub fn always_allow() -> Self {
311 Self {
312 default_response: PromptResult::AllowAlways,
313 }
314 }
315
316 pub fn always_deny() -> Self {
318 Self {
319 default_response: PromptResult::Deny,
320 }
321 }
322
323 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#[derive(Debug, Default)]
351pub struct RecordingPromptHandler {
352 prompts: std::sync::Mutex<Vec<RecordedPrompt>>,
354 response: PromptResult,
356}
357
358#[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 pub fn new(response: PromptResult) -> Self {
369 Self {
370 prompts: std::sync::Mutex::new(Vec::new()),
371 response,
372 }
373 }
374
375 pub fn prompts(&self) -> Vec<RecordedPrompt> {
377 self.prompts
378 .lock()
379 .expect("RecordingPromptHandler mutex poisoned")
380 .clone()
381 }
382
383 pub fn prompt_count(&self) -> usize {
385 self.prompts
386 .lock()
387 .expect("RecordingPromptHandler mutex poisoned")
388 .len()
389 }
390
391 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
439fn atty_check() -> bool {
445 #[cfg(unix)]
447 {
448 use std::os::unix::io::AsRawFd;
449 unsafe { libc::isatty(std::io::stdout().as_raw_fd()) != 0 }
452 }
453
454 #[cfg(windows)]
455 {
456 use std::os::windows::io::AsRawHandle;
457 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 unsafe { GetConsoleMode(handle as _, &mut mode) != 0 }
463 }
464
465 #[cfg(not(any(unix, windows)))]
466 {
467 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}