use nono::{AccessMode, ApprovalBackend, ApprovalDecision, CapabilityRequest, NonoError, Result};
use std::io::{BufRead, IsTerminal, Write};
pub struct TerminalApproval;
impl ApprovalBackend for TerminalApproval {
fn request_capability(&self, request: &CapabilityRequest) -> Result<ApprovalDecision> {
let stderr = std::io::stderr();
if !stderr.is_terminal() {
return Ok(ApprovalDecision::Denied {
reason: "No terminal available for interactive approval".to_string(),
});
}
eprintln!();
eprintln!("[nono] The sandboxed process is requesting additional access:");
eprintln!(
"[nono] Path: {}",
sanitize_for_terminal(&request.path.display().to_string())
);
eprintln!("[nono] Access: {}", format_access_mode(&request.access));
if let Some(ref reason) = request.reason {
eprintln!("[nono] Reason: {}", sanitize_for_terminal(reason));
}
eprintln!("[nono]");
eprint!("[nono] Grant access? [y/N] ");
let _ = std::io::stderr().flush();
let tty = std::fs::File::open("/dev/tty").map_err(|e| {
NonoError::SandboxInit(format!("Failed to open /dev/tty for approval prompt: {e}"))
})?;
let mut reader = std::io::BufReader::new(tty);
let mut input = String::new();
reader.read_line(&mut input).map_err(|e| {
NonoError::SandboxInit(format!("Failed to read approval response: {e}"))
})?;
let input = input.trim().to_lowercase();
if input == "y" || input == "yes" {
eprintln!("[nono] Access granted.");
Ok(ApprovalDecision::Granted)
} else {
eprintln!("[nono] Access denied.");
Ok(ApprovalDecision::Denied {
reason: "User denied the request".to_string(),
})
}
}
fn backend_name(&self) -> &str {
"terminal"
}
}
fn sanitize_for_terminal(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(c) = chars.next() {
if c == '\x1b' {
if let Some(&next) = chars.peek() {
if next == '[' {
chars.next();
for seq_c in chars.by_ref() {
if ('\x40'..='\x7e').contains(&seq_c) {
break;
}
}
} else if matches!(next, ']' | 'P' | '_' | '^' | 'X') {
chars.next();
let mut prev = '\0';
for seq_c in chars.by_ref() {
if seq_c == '\x07' || (prev == '\x1b' && seq_c == '\\') {
break;
}
prev = seq_c;
}
}
}
continue;
}
if c.is_control() {
result.push(' ');
} else {
result.push(c);
}
}
result
}
fn format_access_mode(access: &AccessMode) -> &'static str {
match access {
AccessMode::Read => "read-only",
AccessMode::Write => "write-only",
AccessMode::ReadWrite => "read+write",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_terminal_approval_backend_name() {
let backend = TerminalApproval;
assert_eq!(backend.backend_name(), "terminal");
}
#[test]
fn test_format_access_mode() {
assert_eq!(format_access_mode(&AccessMode::Read), "read-only");
assert_eq!(format_access_mode(&AccessMode::Write), "write-only");
assert_eq!(format_access_mode(&AccessMode::ReadWrite), "read+write");
}
#[test]
fn test_sanitize_clean_input() {
assert_eq!(sanitize_for_terminal("/tmp/harmless"), "/tmp/harmless");
}
#[test]
fn test_sanitize_carriage_return_overwrite() {
let malicious = "/etc/shadow\r/tmp/harmless";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\r'));
assert!(sanitized.contains("/etc/shadow"));
assert!(sanitized.contains("/tmp/harmless"));
}
#[test]
fn test_sanitize_ansi_escape_csi() {
let malicious = "/tmp/\x1b[2K\x1b[1A/etc/shadow";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\x1b'));
assert!(sanitized.contains("/tmp/"));
}
#[test]
fn test_sanitize_ansi_escape_osc() {
let malicious = "/tmp/\x1b]0;evil\x07path";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\x1b'));
assert!(!sanitized.contains('\x07'));
}
#[test]
fn test_sanitize_null_bytes() {
let malicious = "/tmp/\0evil";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\0'));
}
#[test]
fn test_sanitize_all_control_chars_replaced() {
for byte in 0x00u8..=0x1f {
let input = format!("/tmp/{}evil", byte as char);
let sanitized = sanitize_for_terminal(&input);
assert!(
!sanitized.chars().any(|c| c == byte as char),
"Control byte 0x{:02x} should be stripped",
byte
);
}
let del_input = "/tmp/\x7Fevil";
let sanitized = sanitize_for_terminal(del_input);
assert!(!sanitized.contains('\x7F'));
}
#[test]
fn test_sanitize_dcs_sequence() {
let malicious = "/tmp/\x1bPq#0;2;0;0;0#1;2;100;100;0\x1b\\path";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\x1b'));
assert!(sanitized.contains("/tmp/"));
assert!(sanitized.contains("path"));
}
#[test]
fn test_sanitize_apc_sequence() {
let malicious = "/tmp/\x1b_evil-command\x1b\\path";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\x1b'));
assert!(sanitized.contains("/tmp/"));
assert!(sanitized.contains("path"));
}
#[test]
fn test_sanitize_pm_sequence() {
let malicious = "/tmp/\x1b^private-data\x1b\\path";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\x1b'));
assert!(sanitized.contains("/tmp/"));
assert!(sanitized.contains("path"));
}
#[test]
fn test_sanitize_sos_sequence() {
let malicious = "/tmp/\x1bXsome-string\x1b\\path";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\x1b'));
assert!(sanitized.contains("/tmp/"));
assert!(sanitized.contains("path"));
}
#[test]
fn test_sanitize_unterminated_csi() {
let malicious = "/tmp/\x1b[999";
let sanitized = sanitize_for_terminal(malicious);
assert!(!sanitized.contains('\x1b'));
assert!(sanitized.contains("/tmp/"));
}
}