Skip to main content

destructive_command_guard/
interactive.rs

1//! Interactive mode for dcg - allows users to bypass blocks via terminal interaction.
2//!
3//! This module implements the security-critical interactive prompt that allows human users
4//! (but not AI agents) to bypass blocked commands. See `docs/security-model.md` for the
5//! full threat model and design rationale.
6//!
7//! # Security Model
8//!
9//! Interactive mode defaults to a random verification code + timeout, and can
10//! be configured to use other verification methods when explicitly enabled.
11//!
12//! This combination prevents automated bypass by AI agents while remaining usable for humans.
13//!
14//! # Critical Security Checks
15//!
16//! - **TTY detection**: If stdin is not a TTY, interactive mode is disabled (piped input = agent)
17//! - **CI detection**: Interactive mode is disabled in CI environments
18//! - **Code freshness**: Each prompt generates a new code; codes are single-use
19
20use colored::Colorize;
21use rand::RngExt;
22use serde::{Deserialize, Serialize};
23use std::collections::HashSet;
24use std::fmt::Write as FmtWrite;
25use std::io::{self, BufRead, IsTerminal, Write};
26use std::sync::mpsc;
27use std::thread;
28use std::time::Duration;
29
30/// Default timeout for interactive prompts (5 seconds).
31pub const DEFAULT_TIMEOUT_SECONDS: u64 = 5;
32
33/// Default verification code length (4 characters).
34pub const DEFAULT_CODE_LENGTH: usize = 4;
35
36/// Maximum verification code length.
37pub const MAX_CODE_LENGTH: usize = 8;
38
39/// Minimum verification code length.
40pub const MIN_CODE_LENGTH: usize = 4;
41
42/// Maximum timeout in seconds.
43pub const MAX_TIMEOUT_SECONDS: u64 = 30;
44
45/// Minimum timeout in seconds.
46pub const MIN_TIMEOUT_SECONDS: u64 = 1;
47
48/// Character set for verification codes (lowercase, unambiguous).
49/// Excludes visually confusing characters: i, l, o, 0, 1.
50const CODE_CHARSET: &[u8] = b"abcdefghjkmnpqrstuvwxyz23456789";
51
52static SESSION_CODES: std::sync::OnceLock<std::sync::Mutex<VerificationCodeGenerator>> =
53    std::sync::OnceLock::new();
54
55/// Verification method for interactive prompts.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[serde(rename_all = "lowercase")]
58pub enum VerificationMethod {
59    /// Random verification code (default).
60    Code,
61    /// Retype the full command.
62    Command,
63    /// No verification (least secure).
64    None,
65}
66
67impl Default for VerificationMethod {
68    fn default() -> Self {
69        Self::Code
70    }
71}
72
73/// Result of an interactive prompt session.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub enum InteractiveResult {
76    /// User entered correct code and selected an allowlist option.
77    AllowlistRequested(AllowlistScope),
78
79    /// User entered incorrect code.
80    InvalidCode,
81
82    /// Timeout expired before user responded.
83    Timeout,
84
85    /// User cancelled (pressed Enter without input or Ctrl+C).
86    Cancelled,
87
88    /// Interactive mode not available (not a TTY, CI environment, etc.).
89    NotAvailable(NotAvailableReason),
90}
91
92/// Reason why interactive mode is not available.
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub enum NotAvailableReason {
95    /// stdin is not a TTY (piped input, likely from an AI agent).
96    NotTty,
97
98    /// Running in a CI environment.
99    CiEnvironment,
100
101    /// Interactive mode is disabled in configuration.
102    Disabled,
103
104    /// Required environment variable is not set.
105    MissingEnv(String),
106
107    /// Terminal environment is not suitable (TERM=dumb, etc.).
108    UnsuitableTerminal,
109}
110
111impl std::fmt::Display for NotAvailableReason {
112    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
113        match self {
114            Self::NotTty => write!(f, "stdin is not a terminal (TTY)"),
115            Self::CiEnvironment => write!(f, "running in CI environment"),
116            Self::Disabled => write!(f, "interactive mode is disabled in configuration"),
117            Self::MissingEnv(var) => write!(f, "required environment variable '{var}' is not set"),
118            Self::UnsuitableTerminal => write!(f, "terminal environment is not suitable"),
119        }
120    }
121}
122
123/// Scope for allowlisting a command.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum AllowlistScope {
126    /// Allow this single execution only.
127    Once,
128
129    /// Allow for the current session (until terminal closes).
130    Session,
131
132    /// Allow temporarily (24 hours by default).
133    Temporary(Duration),
134
135    /// Add to permanent allowlist.
136    Permanent,
137}
138
139impl std::fmt::Display for AllowlistScope {
140    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
141        match self {
142            Self::Once => write!(f, "once (this execution only)"),
143            Self::Session => write!(f, "session (until terminal closes)"),
144            Self::Temporary(d) => write!(f, "temporary ({} hours)", d.as_secs() / 3600),
145            Self::Permanent => write!(f, "permanent (added to allowlist)"),
146        }
147    }
148}
149
150/// Stateful verification-code generator for one interactive session.
151///
152/// The stateless [`generate_verification_code`] helper is useful for direct
153/// callers and tests. Interactive prompts use this generator so a code that
154/// has already been displayed in the current process is not reused.
155#[derive(Debug, Default)]
156pub struct VerificationCodeGenerator {
157    used_codes: HashSet<String>,
158}
159
160impl VerificationCodeGenerator {
161    /// Create an empty session generator.
162    #[must_use]
163    pub fn new() -> Self {
164        Self::default()
165    }
166
167    /// Generate a single-use verification code for this generator.
168    ///
169    /// If the code space for the requested length is exhausted, previously
170    /// issued codes at that length are forgotten so the prompt remains usable.
171    #[must_use]
172    pub fn generate(&mut self, length: usize) -> String {
173        let length = length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH);
174        self.clear_length_if_exhausted(length);
175
176        loop {
177            let code = generate_verification_code(length);
178            if self.used_codes.insert(code.clone()) {
179                return code;
180            }
181        }
182    }
183
184    fn clear_length_if_exhausted(&mut self, length: usize) {
185        let space_size = CODE_CHARSET
186            .len()
187            .saturating_pow(u32::try_from(length).unwrap_or(u32::MAX));
188        let used_at_length = self
189            .used_codes
190            .iter()
191            .filter(|code| code.len() == length)
192            .count();
193
194        if used_at_length >= space_size {
195            self.used_codes.retain(|code| code.len() != length);
196        }
197    }
198}
199
200/// Configuration for interactive mode.
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct InteractiveConfig {
203    /// Whether interactive mode is enabled.
204    pub enabled: bool,
205
206    /// Verification method ("code", "command", "none").
207    pub verification: VerificationMethod,
208
209    /// Timeout in seconds for user response.
210    pub timeout_seconds: u64,
211
212    /// Length of verification code.
213    pub code_length: usize,
214
215    /// Maximum attempts before lockout.
216    pub max_attempts: u32,
217
218    /// Whether to allow fallback when not a TTY (always block in that case).
219    pub allow_non_tty_fallback: bool,
220
221    /// Disable interactive mode in CI environments.
222    pub disable_in_ci: bool,
223
224    /// Require this env var to be set to enable interactive mode.
225    pub require_env: Option<String>,
226}
227
228impl Default for InteractiveConfig {
229    fn default() -> Self {
230        Self {
231            enabled: false, // Disabled by default for safety
232            verification: VerificationMethod::Code,
233            timeout_seconds: DEFAULT_TIMEOUT_SECONDS,
234            code_length: DEFAULT_CODE_LENGTH,
235            max_attempts: 3,
236            allow_non_tty_fallback: true,
237            disable_in_ci: true,
238            require_env: None,
239        }
240    }
241}
242
243impl InteractiveConfig {
244    /// Create a new interactive config with default values.
245    #[must_use]
246    pub fn new() -> Self {
247        Self::default()
248    }
249
250    /// Get the timeout as a `Duration`.
251    #[must_use]
252    pub fn timeout(&self) -> Duration {
253        Duration::from_secs(
254            self.timeout_seconds
255                .clamp(MIN_TIMEOUT_SECONDS, MAX_TIMEOUT_SECONDS),
256        )
257    }
258
259    /// Get the code length, clamped to valid range.
260    #[must_use]
261    pub fn effective_code_length(&self) -> usize {
262        self.code_length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH)
263    }
264}
265
266/// Generate a cryptographically secure verification code.
267///
268/// # Arguments
269///
270/// * `length` - The length of the code to generate (will be clamped to valid range)
271///
272/// # Returns
273///
274/// A lowercase alphanumeric string of the specified length.
275#[must_use]
276pub fn generate_verification_code(length: usize) -> String {
277    let length = length.clamp(MIN_CODE_LENGTH, MAX_CODE_LENGTH);
278    let mut rng = rand::rng();
279
280    (0..length)
281        .map(|_| {
282            let idx = rng.random_range(0..CODE_CHARSET.len());
283            CODE_CHARSET[idx] as char
284        })
285        .collect()
286}
287
288fn generate_session_verification_code(length: usize) -> String {
289    let generator =
290        SESSION_CODES.get_or_init(|| std::sync::Mutex::new(VerificationCodeGenerator::new()));
291    let mut guard = generator
292        .lock()
293        .unwrap_or_else(std::sync::PoisonError::into_inner);
294    guard.generate(length)
295}
296
297/// Validate a user-provided verification code.
298///
299/// Comparison is case-insensitive and ignores leading/trailing whitespace.
300#[must_use]
301pub fn validate_code(input: &str, expected: &str) -> bool {
302    input.trim().eq_ignore_ascii_case(expected)
303}
304
305/// Check if interactive mode is available in the current environment.
306///
307/// Returns `Ok(())` if interactive mode can be used, or `Err(reason)` if not.
308///
309/// # Security
310///
311/// This is a critical security check. If stdin is not a TTY, we're likely
312/// receiving piped input from an AI agent, and interactive mode MUST be disabled.
313pub fn check_interactive_available(config: &InteractiveConfig) -> Result<(), NotAvailableReason> {
314    let stdin_is_tty = io::stdin().is_terminal();
315    let ci_environment = is_ci_environment();
316    let term_is_dumb = matches!(std::env::var("TERM").as_deref(), Ok("dumb"));
317
318    check_interactive_available_with_context(config, stdin_is_tty, ci_environment, term_is_dumb)
319}
320
321fn check_interactive_available_with_context(
322    config: &InteractiveConfig,
323    stdin_is_tty: bool,
324    ci_environment: bool,
325    term_is_dumb: bool,
326) -> Result<(), NotAvailableReason> {
327    // Check if interactive mode is enabled
328    if !config.enabled {
329        return Err(NotAvailableReason::Disabled);
330    }
331
332    if let Some(var) = config.require_env.as_ref() {
333        if std::env::var(var).is_err() {
334            return Err(NotAvailableReason::MissingEnv(var.clone()));
335        }
336    }
337
338    // Critical: Check if stdin is a TTY
339    // If not, we're likely receiving piped input from an AI agent
340    if !stdin_is_tty {
341        return Err(NotAvailableReason::NotTty);
342    }
343
344    // Check for CI environment
345    if config.disable_in_ci && ci_environment {
346        return Err(NotAvailableReason::CiEnvironment);
347    }
348
349    // Check for dumb terminal
350    if term_is_dumb {
351        return Err(NotAvailableReason::UnsuitableTerminal);
352    }
353
354    Ok(())
355}
356
357fn is_ci_environment() -> bool {
358    ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS"]
359        .iter()
360        .any(|var| std::env::var(var).is_ok())
361}
362
363/// Display the interactive prompt and wait for user input.
364///
365/// # Arguments
366///
367/// * `command` - The blocked command
368/// * `reason` - Why the command was blocked
369/// * `rule_id` - Optional rule ID that triggered the block
370/// * `config` - Interactive mode configuration
371///
372/// # Returns
373///
374/// The result of the interactive session.
375///
376/// # Security
377///
378/// This function includes multiple security checks:
379/// - TTY detection before prompting
380/// - Random verification code generation
381/// - Timeout enforcement
382/// - Single-use codes (new code on each call)
383#[allow(clippy::too_many_lines)]
384pub fn run_interactive_prompt(
385    command: &str,
386    reason: &str,
387    rule_id: Option<&str>,
388    config: &InteractiveConfig,
389) -> InteractiveResult {
390    // Security check: verify interactive mode is available
391    if let Err(reason) = check_interactive_available(config) {
392        return InteractiveResult::NotAvailable(reason);
393    }
394
395    let timeout = config.timeout();
396
397    match config.verification {
398        VerificationMethod::Code => {
399            let code = generate_session_verification_code(config.effective_code_length());
400            display_prompt(
401                command,
402                reason,
403                rule_id,
404                VerificationMethod::Code,
405                Some(&code),
406                timeout,
407            );
408
409            // Read input with timeout
410            match read_input_with_timeout(timeout) {
411                Ok(input) => {
412                    let input = input.trim();
413
414                    // Empty input = cancelled
415                    if input.is_empty() {
416                        return InteractiveResult::Cancelled;
417                    }
418
419                    // Check verification code (case-insensitive)
420                    if validate_code(input, &code) {
421                        // Code correct - show scope selection
422                        match select_allowlist_scope(timeout) {
423                            Ok(scope) => InteractiveResult::AllowlistRequested(scope),
424                            Err(_) => InteractiveResult::Cancelled,
425                        }
426                    } else {
427                        InteractiveResult::InvalidCode
428                    }
429                }
430                Err(InputError::Timeout) => InteractiveResult::Timeout,
431                Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
432            }
433        }
434        VerificationMethod::Command => {
435            display_prompt(
436                command,
437                reason,
438                rule_id,
439                VerificationMethod::Command,
440                None,
441                timeout,
442            );
443
444            match read_input_with_timeout(timeout) {
445                Ok(input) => {
446                    let input = input.trim();
447
448                    if input.is_empty() {
449                        return InteractiveResult::Cancelled;
450                    }
451
452                    if input == command {
453                        match select_allowlist_scope(timeout) {
454                            Ok(scope) => InteractiveResult::AllowlistRequested(scope),
455                            Err(_) => InteractiveResult::Cancelled,
456                        }
457                    } else {
458                        InteractiveResult::InvalidCode
459                    }
460                }
461                Err(InputError::Timeout) => InteractiveResult::Timeout,
462                Err(InputError::Io(_) | InputError::Interrupted) => InteractiveResult::Cancelled,
463            }
464        }
465        VerificationMethod::None => {
466            display_prompt(
467                command,
468                reason,
469                rule_id,
470                VerificationMethod::None,
471                None,
472                timeout,
473            );
474
475            match select_allowlist_scope(timeout) {
476                Ok(scope) => InteractiveResult::AllowlistRequested(scope),
477                Err(_) => InteractiveResult::Cancelled,
478            }
479        }
480    }
481}
482
483/// Sanitize an attacker-controlled string for safe display in the interactive
484/// prompt.
485///
486/// The blocked command and the rule-supplied reason both flow through here
487/// before any styling is applied. A malicious command can contain CSI/OSC/SGR
488/// sequences (which can fake the prompt boundary, change the terminal title,
489/// or recolor the page) or raw C0 control bytes like `\r` and `\x07` (which
490/// can rewrite the visible prompt or beep the terminal). Either could mislead
491/// the human verifier — the entire value of the interactive prompt is that
492/// the human reads exactly what the agent tried to run.
493///
494/// Output rules:
495/// - CSI (`ESC [ ...` final byte `0x40..=0x7E`) sequences are dropped.
496/// - OSC (`ESC ] ...` terminated by `BEL` or `ESC \\`) sequences are dropped.
497/// - Any other 2-byte `ESC <X>` sequence is dropped.
498/// - Remaining C0 control bytes (`0x00..=0x1F`), DEL (`0x7F`), and C1 control
499///   bytes (`0x80..=0x9F`) are rendered as their visible escape form
500///   (`\\n`, `\\r`, `\\t`, `\\xNN`) so the user can still see the original
501///   bytes without the terminal acting on them.
502fn sanitize_for_display(input: &str) -> String {
503    #[derive(Copy, Clone)]
504    enum State {
505        Normal,
506        EscOpen,
507        Csi,
508        Osc,
509        OscWantSt,
510    }
511
512    let mut out = String::with_capacity(input.len());
513    let mut state = State::Normal;
514
515    let push_visible_control = |out: &mut String, c: char| match c {
516        '\n' => out.push_str("\\n"),
517        '\r' => out.push_str("\\r"),
518        '\t' => out.push_str("\\t"),
519        _ => {
520            let cp = c as u32;
521            if cp <= 0xFF {
522                let _ = write!(out, "\\x{cp:02X}");
523            } else {
524                let _ = write!(out, "\\u{{{cp:04X}}}");
525            }
526        }
527    };
528
529    for c in input.chars() {
530        match state {
531            State::Normal => {
532                if c == '\x1b' {
533                    state = State::EscOpen;
534                } else if (c as u32) <= 0x1F || c == '\x7f' || (0x80..=0x9F).contains(&(c as u32)) {
535                    push_visible_control(&mut out, c);
536                } else {
537                    out.push(c);
538                }
539            }
540            State::EscOpen => {
541                state = match c {
542                    '[' => State::Csi,
543                    ']' => State::Osc,
544                    _ => State::Normal,
545                };
546            }
547            State::Csi => {
548                let cp = c as u32;
549                if (0x40..=0x7E).contains(&cp) {
550                    state = State::Normal;
551                }
552            }
553            State::Osc => {
554                if c == '\x07' {
555                    state = State::Normal;
556                } else if c == '\x1b' {
557                    state = State::OscWantSt;
558                }
559            }
560            State::OscWantSt => {
561                state = if c == '\\' {
562                    State::Normal
563                } else {
564                    State::EscOpen
565                };
566            }
567        }
568    }
569
570    out
571}
572
573/// Display the interactive prompt to stderr.
574///
575/// Shows a formatted box with the blocked command, available options, and
576/// verification code. The user must type the verification code to proceed
577/// with any allowlist action.
578fn display_prompt(
579    command: &str,
580    reason: &str,
581    rule_id: Option<&str>,
582    verification: VerificationMethod,
583    code: Option<&str>,
584    timeout: Duration,
585) {
586    let command = sanitize_for_display(command);
587    let reason = sanitize_for_display(reason);
588    let stderr = io::stderr();
589    let mut handle = stderr.lock();
590
591    const WIDTH: usize = 66;
592
593    // Helper to write a padded line
594    let write_line = |handle: &mut std::io::StderrLock<'_>, content: &str, style: &str| {
595        let visible_len = content.chars().count();
596        let padding = WIDTH.saturating_sub(visible_len);
597        match style {
598            "red" => {
599                let _ = writeln!(
600                    handle,
601                    "{}{}{}{}",
602                    "\u{2502}".red(),
603                    content,
604                    " ".repeat(padding),
605                    "\u{2502}".red()
606                );
607            }
608            _ => {
609                let _ = writeln!(
610                    handle,
611                    "{}{}{}{}",
612                    "\u{2502}".red(),
613                    content,
614                    " ".repeat(padding),
615                    "\u{2502}".red()
616                );
617            }
618        }
619    };
620
621    // Top border
622    let _ = writeln!(
623        handle,
624        "{}{}{}",
625        "\u{256d}".red(),
626        "\u{2500}".repeat(WIDTH).red(),
627        "\u{256e}".red()
628    );
629
630    // Header with blocked command (truncated if needed)
631    let cmd_prefix = "  \u{1f6d1} BLOCKED: ";
632    let max_cmd_len = WIDTH - cmd_prefix.chars().count() - 1;
633    let display_cmd = if command.chars().count() > max_cmd_len {
634        format!(
635            "{}...",
636            command.chars().take(max_cmd_len - 3).collect::<String>()
637        )
638    } else {
639        command.clone()
640    };
641    let header = format!("{cmd_prefix}{display_cmd}");
642    let header_padding = WIDTH.saturating_sub(header.chars().count());
643    let _ = writeln!(
644        handle,
645        "{}  {} {}{}{}",
646        "\u{2502}".red(),
647        "\u{1f6d1}",
648        format!("BLOCKED: {display_cmd}").white().bold(),
649        " ".repeat(header_padding.saturating_sub(2)),
650        "\u{2502}".red()
651    );
652
653    // Separator
654    let _ = writeln!(
655        handle,
656        "{}{}{}",
657        "\u{251c}".red(),
658        "\u{2500}".repeat(WIDTH).red().dimmed(),
659        "\u{2524}".red()
660    );
661
662    // Rule ID if available
663    if let Some(rule) = rule_id {
664        let rule_line = format!("  Rule: {rule}");
665        let _ = writeln!(
666            handle,
667            "{}{}{}{}",
668            "\u{2502}".red(),
669            rule_line.yellow(),
670            " ".repeat(WIDTH.saturating_sub(rule_line.chars().count())),
671            "\u{2502}".red()
672        );
673    }
674
675    // Reason (truncated if too long)
676    let reason_line = format!("  Reason: {reason}");
677    let truncated_reason = if reason_line.chars().count() > WIDTH - 2 {
678        format!(
679            "{}...",
680            reason_line.chars().take(WIDTH - 5).collect::<String>()
681        )
682    } else {
683        reason_line
684    };
685    let _ = writeln!(
686        handle,
687        "{}{}{}{}",
688        "\u{2502}".red(),
689        truncated_reason.bright_black(),
690        " ".repeat(WIDTH.saturating_sub(truncated_reason.chars().count())),
691        "\u{2502}".red()
692    );
693
694    // Separator
695    let _ = writeln!(
696        handle,
697        "{}{}{}",
698        "\u{251c}".red(),
699        "\u{2500}".repeat(WIDTH).red().dimmed(),
700        "\u{2524}".red()
701    );
702
703    // Options preview (shown for user awareness)
704    let options = [
705        ("o", "Allowlist once (this execution only)"),
706        ("t", "Allowlist temporarily (24 hours)"),
707        ("p", "Allowlist permanently (add to project)"),
708        ("Enter", "Keep blocked"),
709    ];
710
711    for (key, desc) in &options {
712        let option_line = if *key == "Enter" {
713            format!("  [{}] {}", key.bright_black(), desc.bright_black())
714        } else {
715            format!("  [{}] {}", key.cyan(), desc.white())
716        };
717        // Calculate visible length (without ANSI codes)
718        let visible_len = 2 + 1 + key.len() + 1 + 1 + desc.len(); // "  [" + key + "] " + desc
719        let padding = WIDTH.saturating_sub(visible_len);
720        let _ = writeln!(
721            handle,
722            "{}{}{}{}",
723            "\u{2502}".red(),
724            option_line,
725            " ".repeat(padding),
726            "\u{2502}".red()
727        );
728    }
729
730    // Empty line
731    write_line(&mut handle, "", "red");
732
733    // Separator
734    let _ = writeln!(
735        handle,
736        "{}{}{}",
737        "\u{251c}".red(),
738        "\u{2500}".repeat(WIDTH).red().dimmed(),
739        "\u{2524}".red()
740    );
741
742    let mut show_input_prompt = true;
743
744    match verification {
745        VerificationMethod::Code => {
746            let code = code.unwrap_or_default();
747            let verify_prefix = "  To proceed, type: ";
748            let verify_visible_len = verify_prefix.len() + code.len();
749            let verify_padding = WIDTH.saturating_sub(verify_visible_len);
750            let _ = writeln!(
751                handle,
752                "{}{}{}{}{}",
753                "\u{2502}".red(),
754                verify_prefix.white(),
755                code.bright_yellow().bold(),
756                " ".repeat(verify_padding),
757                "\u{2502}".red()
758            );
759
760            // Timeout indicator
761            let timeout_secs = timeout.as_secs();
762            let timeout_line = format!("  ({timeout_secs} seconds remaining)");
763            let _ = writeln!(
764                handle,
765                "{}{}{}{}",
766                "\u{2502}".red(),
767                timeout_line.bright_black(),
768                " ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
769                "\u{2502}".red()
770            );
771        }
772        VerificationMethod::Command => {
773            let verify_line = "  To proceed, retype the full command:";
774            let _ = writeln!(
775                handle,
776                "{}{}{}{}",
777                "\u{2502}".red(),
778                verify_line.white(),
779                " ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
780                "\u{2502}".red()
781            );
782
783            let timeout_secs = timeout.as_secs();
784            let timeout_line = format!("  ({timeout_secs} seconds remaining)");
785            let _ = writeln!(
786                handle,
787                "{}{}{}{}",
788                "\u{2502}".red(),
789                timeout_line.bright_black(),
790                " ".repeat(WIDTH.saturating_sub(timeout_line.chars().count())),
791                "\u{2502}".red()
792            );
793        }
794        VerificationMethod::None => {
795            let verify_line = "  Verification disabled (least secure).";
796            let _ = writeln!(
797                handle,
798                "{}{}{}{}",
799                "\u{2502}".red(),
800                verify_line.bright_black(),
801                " ".repeat(WIDTH.saturating_sub(verify_line.chars().count())),
802                "\u{2502}".red()
803            );
804            show_input_prompt = false;
805        }
806    }
807
808    // Bottom border
809    let _ = writeln!(
810        handle,
811        "{}{}{}",
812        "\u{2570}".red(),
813        "\u{2500}".repeat(WIDTH).red(),
814        "\u{256f}".red()
815    );
816
817    if show_input_prompt {
818        // Input prompt
819        let _ = write!(handle, "{} ", ">".green().bold());
820        let _ = handle.flush();
821    }
822}
823
824/// Error type for input reading.
825#[derive(Debug)]
826enum InputError {
827    Timeout,
828    #[allow(dead_code)] // Error value preserved for future logging/debugging
829    Io(io::Error),
830    Interrupted,
831}
832
833fn read_input_with_timeout(timeout: Duration) -> Result<String, InputError> {
834    read_line_with_timeout(
835        || {
836            let stdin = io::stdin();
837            let handle = stdin.lock();
838            let mut reader = io::BufReader::new(handle);
839            read_line_from_reader(&mut reader)
840        },
841        timeout,
842    )
843}
844
845fn read_line_with_timeout<F>(read_line: F, timeout: Duration) -> Result<String, InputError>
846where
847    F: FnOnce() -> Result<String, InputError> + Send + 'static,
848{
849    let (tx, rx) = mpsc::sync_channel(1);
850    let _input_thread = thread::Builder::new()
851        .name("dcg-interactive-input".to_string())
852        .spawn(move || {
853            let _ = tx.send(read_line());
854        })
855        .map_err(InputError::Io)?;
856
857    match rx.recv_timeout(timeout) {
858        Ok(result) => result,
859        Err(mpsc::RecvTimeoutError::Timeout) => Err(InputError::Timeout),
860        Err(mpsc::RecvTimeoutError::Disconnected) => Err(InputError::Interrupted),
861    }
862}
863
864fn read_line_from_reader<R>(reader: &mut R) -> Result<String, InputError>
865where
866    R: BufRead,
867{
868    let mut input = String::new();
869    match reader.read_line(&mut input) {
870        Ok(0) => Err(InputError::Interrupted), // EOF
871        Ok(_) => Ok(input),
872        Err(e) if e.kind() == io::ErrorKind::Interrupted => Err(InputError::Interrupted),
873        Err(e) => Err(InputError::Io(e)),
874    }
875}
876
877/// Display the scope selection menu and get user choice.
878fn select_allowlist_scope(timeout: Duration) -> Result<AllowlistScope, InputError> {
879    let stderr = io::stderr();
880    let mut handle = stderr.lock();
881
882    let _ = writeln!(handle);
883    let _ = writeln!(handle, "{}", "Verification successful!".green().bold());
884    let _ = writeln!(handle);
885    let _ = writeln!(handle, "Select allowlist scope:");
886    let _ = writeln!(handle, "  {} Once (this execution only)", "[o]".cyan());
887    let _ = writeln!(handle, "  {} Session (until terminal closes)", "[s]".cyan());
888    let _ = writeln!(handle, "  {} Temporary (24 hours)", "[t]".cyan());
889    let _ = writeln!(
890        handle,
891        "  {} Permanent (add to project allowlist)",
892        "[p]".cyan()
893    );
894    let _ = writeln!(handle);
895    let _ = write!(handle, "{} ", "Choice [o/s/t/p]:".white());
896    let _ = handle.flush();
897
898    match read_input_with_timeout(timeout) {
899        Ok(input) => {
900            let choice = input.trim().to_lowercase();
901            match choice.as_str() {
902                "o" | "once" | "1" => Ok(AllowlistScope::Once),
903                "s" | "session" | "2" => Ok(AllowlistScope::Session),
904                "t" | "temporary" | "temp" | "3" => {
905                    Ok(AllowlistScope::Temporary(Duration::from_secs(24 * 3600)))
906                }
907                "p" | "permanent" | "perm" | "4" => Ok(AllowlistScope::Permanent),
908                // Empty / unrecognized input must NOT silently allow.
909                // The denial UI tells the user `[Enter]` = "Keep blocked",
910                // and for `VerificationMethod::None` this prompt is the
911                // ONLY gate before allowing — defaulting to `Once` here
912                // turns Enter into a hands-free allow. Treat both cases
913                // as an explicit cancel so the caller (run_interactive_prompt)
914                // returns `InteractiveResult::Cancelled` and the command
915                // stays blocked.
916                "" => Err(InputError::Interrupted),
917                _ => Err(InputError::Interrupted),
918            }
919        }
920        Err(err) => Err(err),
921    }
922}
923
924/// Print a message indicating interactive mode is not available.
925pub fn print_not_available_message(reason: &NotAvailableReason) {
926    let stderr = io::stderr();
927    let mut handle = stderr.lock();
928
929    let _ = writeln!(
930        handle,
931        "{} Interactive mode not available: {}",
932        "[dcg]".bright_black(),
933        reason
934    );
935
936    if matches!(reason, NotAvailableReason::NotTty) {
937        let _ = writeln!(
938            handle,
939            "{}   This is a security feature to prevent automated bypass.",
940            " ".repeat(5)
941        );
942        let _ = writeln!(
943            handle,
944            "{}   Run dcg in an interactive terminal to use this feature.",
945            " ".repeat(5)
946        );
947    } else if let NotAvailableReason::MissingEnv(var) = reason {
948        let _ = writeln!(
949            handle,
950            "{}   Set {} to enable interactive prompts.",
951            " ".repeat(5),
952            var
953        );
954    }
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960    use std::ffi::OsString;
961    use std::sync::{Mutex, OnceLock};
962    use std::time::Instant;
963
964    const CI_ENV_VARS: [&str; 5] = ["CI", "GITHUB_ACTIONS", "GITLAB_CI", "JENKINS", "TRAVIS"];
965
966    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
967        static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
968        ENV_LOCK
969            .get_or_init(|| Mutex::new(()))
970            .lock()
971            .expect("env lock poisoned")
972    }
973
974    fn with_clean_ci_env<F>(f: F)
975    where
976        F: FnOnce(),
977    {
978        let _guard = env_lock();
979        let saved: Vec<(&str, Option<OsString>)> = CI_ENV_VARS
980            .iter()
981            .map(|key| (*key, std::env::var_os(key)))
982            .collect();
983
984        for key in CI_ENV_VARS {
985            unsafe {
986                std::env::remove_var(key);
987            }
988        }
989
990        f();
991
992        for (key, value) in saved {
993            match value {
994                Some(existing) => unsafe {
995                    std::env::set_var(key, existing);
996                },
997                None => unsafe {
998                    std::env::remove_var(key);
999                },
1000            }
1001        }
1002    }
1003
1004    #[test]
1005    fn test_generate_verification_code_length() {
1006        let code = generate_verification_code(4);
1007        assert_eq!(code.len(), 4);
1008
1009        let code = generate_verification_code(6);
1010        assert_eq!(code.len(), 6);
1011
1012        let code = generate_verification_code(8);
1013        assert_eq!(code.len(), 8);
1014    }
1015
1016    #[test]
1017    fn test_generate_verification_code_clamps_length() {
1018        // Below minimum
1019        let code = generate_verification_code(2);
1020        assert_eq!(code.len(), MIN_CODE_LENGTH);
1021
1022        // Above maximum
1023        let code = generate_verification_code(20);
1024        assert_eq!(code.len(), MAX_CODE_LENGTH);
1025    }
1026
1027    #[test]
1028    fn test_generate_verification_code_valid_characters() {
1029        let code = generate_verification_code(100); // Generate long code for coverage
1030        for c in code.chars() {
1031            assert!(
1032                c.is_ascii_lowercase() || c.is_ascii_digit(),
1033                "Invalid character in code: {c}"
1034            );
1035        }
1036    }
1037
1038    #[test]
1039    fn test_generate_verification_code_randomness() {
1040        // Generate multiple codes and verify they're not all the same
1041        let codes: Vec<String> = (0..10).map(|_| generate_verification_code(4)).collect();
1042        let unique_count = codes.iter().collect::<std::collections::HashSet<_>>().len();
1043
1044        // With 31^4 = 923,521 possible codes, getting duplicates in 10 tries is unlikely
1045        assert!(
1046            unique_count > 5,
1047            "Generated codes should be mostly unique, got {unique_count} unique out of 10"
1048        );
1049    }
1050
1051    #[test]
1052    fn test_verification_code_generator_avoids_reuse() {
1053        let mut generator = VerificationCodeGenerator::new();
1054        let mut seen = HashSet::new();
1055
1056        for _ in 0..256 {
1057            let code = generator.generate(DEFAULT_CODE_LENGTH);
1058            assert_eq!(code.len(), DEFAULT_CODE_LENGTH);
1059            assert!(seen.insert(code.clone()), "code reused: {code}");
1060        }
1061
1062        assert_eq!(generator.used_codes.len(), seen.len());
1063    }
1064
1065    #[test]
1066    fn test_verification_code_generator_tracks_lengths_independently() {
1067        let mut generator = VerificationCodeGenerator::new();
1068        let short_code = generator.generate(MIN_CODE_LENGTH);
1069        let long_code = generator.generate(MAX_CODE_LENGTH);
1070
1071        assert_eq!(short_code.len(), MIN_CODE_LENGTH);
1072        assert_eq!(long_code.len(), MAX_CODE_LENGTH);
1073        assert!(generator.used_codes.contains(&short_code));
1074        assert!(generator.used_codes.contains(&long_code));
1075    }
1076
1077    #[test]
1078    fn test_code_charset_excludes_ambiguous_chars() {
1079        let charset = std::str::from_utf8(CODE_CHARSET).unwrap();
1080        for ch in ['i', 'l', 'o', '0', '1'] {
1081            assert!(!charset.contains(ch), "charset should not contain '{ch}'");
1082        }
1083    }
1084
1085    #[test]
1086    fn test_validate_code_case_insensitive() {
1087        assert!(validate_code("AbC", "abc"));
1088        assert!(validate_code(" abc ", "aBc"));
1089        assert!(!validate_code("abcd", "abc"));
1090    }
1091
1092    #[test]
1093    fn test_interactive_config_defaults() {
1094        let config = InteractiveConfig::default();
1095        assert!(!config.enabled);
1096        assert_eq!(config.verification, VerificationMethod::Code);
1097        assert_eq!(config.timeout_seconds, DEFAULT_TIMEOUT_SECONDS);
1098        assert_eq!(config.code_length, DEFAULT_CODE_LENGTH);
1099        assert_eq!(config.max_attempts, 3);
1100        assert!(config.allow_non_tty_fallback);
1101        assert!(config.disable_in_ci);
1102        assert!(config.require_env.is_none());
1103    }
1104
1105    #[test]
1106    fn test_interactive_config_timeout() {
1107        let mut config = InteractiveConfig::default();
1108
1109        config.timeout_seconds = 10;
1110        assert_eq!(config.timeout(), Duration::from_secs(10));
1111
1112        // Test clamping to minimum
1113        config.timeout_seconds = 0;
1114        assert_eq!(config.timeout(), Duration::from_secs(MIN_TIMEOUT_SECONDS));
1115
1116        // Test clamping to maximum
1117        config.timeout_seconds = 100;
1118        assert_eq!(config.timeout(), Duration::from_secs(MAX_TIMEOUT_SECONDS));
1119    }
1120
1121    #[test]
1122    fn test_interactive_config_effective_code_length() {
1123        let mut config = InteractiveConfig::default();
1124
1125        config.code_length = 6;
1126        assert_eq!(config.effective_code_length(), 6);
1127
1128        // Test clamping to minimum
1129        config.code_length = 1;
1130        assert_eq!(config.effective_code_length(), MIN_CODE_LENGTH);
1131
1132        // Test clamping to maximum
1133        config.code_length = 100;
1134        assert_eq!(config.effective_code_length(), MAX_CODE_LENGTH);
1135    }
1136
1137    #[test]
1138    fn test_not_available_reason_display() {
1139        assert_eq!(
1140            NotAvailableReason::NotTty.to_string(),
1141            "stdin is not a terminal (TTY)"
1142        );
1143        assert_eq!(
1144            NotAvailableReason::CiEnvironment.to_string(),
1145            "running in CI environment"
1146        );
1147        assert_eq!(
1148            NotAvailableReason::Disabled.to_string(),
1149            "interactive mode is disabled in configuration"
1150        );
1151        assert_eq!(
1152            NotAvailableReason::MissingEnv("DCG_INTERACTIVE".to_string()).to_string(),
1153            "required environment variable 'DCG_INTERACTIVE' is not set"
1154        );
1155        assert_eq!(
1156            NotAvailableReason::UnsuitableTerminal.to_string(),
1157            "terminal environment is not suitable"
1158        );
1159    }
1160
1161    #[test]
1162    fn test_allowlist_scope_display() {
1163        assert_eq!(
1164            AllowlistScope::Once.to_string(),
1165            "once (this execution only)"
1166        );
1167        assert_eq!(
1168            AllowlistScope::Session.to_string(),
1169            "session (until terminal closes)"
1170        );
1171        assert_eq!(
1172            AllowlistScope::Temporary(Duration::from_secs(24 * 3600)).to_string(),
1173            "temporary (24 hours)"
1174        );
1175        assert_eq!(
1176            AllowlistScope::Permanent.to_string(),
1177            "permanent (added to allowlist)"
1178        );
1179    }
1180
1181    #[test]
1182    fn test_check_interactive_disabled() {
1183        let config = InteractiveConfig {
1184            enabled: false,
1185            ..Default::default()
1186        };
1187        assert_eq!(
1188            check_interactive_available(&config),
1189            Err(NotAvailableReason::Disabled)
1190        );
1191    }
1192
1193    #[test]
1194    fn test_check_interactive_not_tty() {
1195        let config = InteractiveConfig {
1196            enabled: true,
1197            ..Default::default()
1198        };
1199        assert_eq!(
1200            check_interactive_available_with_context(&config, false, false, false),
1201            Err(NotAvailableReason::NotTty)
1202        );
1203    }
1204
1205    #[test]
1206    fn test_check_interactive_ci_environment_blocked() {
1207        let config = InteractiveConfig {
1208            enabled: true,
1209            disable_in_ci: true,
1210            ..Default::default()
1211        };
1212        assert_eq!(
1213            check_interactive_available_with_context(&config, true, true, false),
1214            Err(NotAvailableReason::CiEnvironment)
1215        );
1216    }
1217
1218    #[test]
1219    fn test_check_interactive_ci_environment_allowed_when_disabled() {
1220        let config = InteractiveConfig {
1221            enabled: true,
1222            disable_in_ci: false,
1223            ..Default::default()
1224        };
1225        assert_eq!(
1226            check_interactive_available_with_context(&config, true, true, false),
1227            Ok(())
1228        );
1229    }
1230
1231    #[test]
1232    fn test_is_ci_environment_false_when_no_known_vars_are_set() {
1233        with_clean_ci_env(|| {
1234            assert!(
1235                !is_ci_environment(),
1236                "Expected CI detection to be false with no CI env vars set"
1237            );
1238        });
1239    }
1240
1241    #[test]
1242    fn test_is_ci_environment_detects_each_supported_variable() {
1243        for key in CI_ENV_VARS {
1244            with_clean_ci_env(|| {
1245                unsafe {
1246                    std::env::set_var(key, "1");
1247                }
1248                assert!(
1249                    is_ci_environment(),
1250                    "Expected CI detection to be true when {key} is set"
1251                );
1252            });
1253        }
1254    }
1255
1256    #[test]
1257    fn test_check_interactive_unsuitable_terminal() {
1258        let config = InteractiveConfig {
1259            enabled: true,
1260            ..Default::default()
1261        };
1262        assert_eq!(
1263            check_interactive_available_with_context(&config, true, false, true),
1264            Err(NotAvailableReason::UnsuitableTerminal)
1265        );
1266    }
1267
1268    #[test]
1269    fn test_check_interactive_missing_required_env() {
1270        let config = InteractiveConfig {
1271            enabled: true,
1272            require_env: Some("DCG_INTERACTIVE_TEST_SENTINEL_UNSET".to_string()),
1273            ..Default::default()
1274        };
1275        assert_eq!(
1276            check_interactive_available_with_context(&config, true, false, false),
1277            Err(NotAvailableReason::MissingEnv(
1278                "DCG_INTERACTIVE_TEST_SENTINEL_UNSET".to_string()
1279            ))
1280        );
1281    }
1282
1283    #[test]
1284    fn test_check_interactive_available_when_requirements_met() {
1285        let config = InteractiveConfig {
1286            enabled: true,
1287            disable_in_ci: true,
1288            require_env: None,
1289            ..Default::default()
1290        };
1291        assert_eq!(
1292            check_interactive_available_with_context(&config, true, false, false),
1293            Ok(())
1294        );
1295    }
1296
1297    #[test]
1298    fn test_read_line_with_timeout_returns_input_before_deadline() {
1299        let input = read_line_with_timeout(
1300            || Ok("verification-code\n".to_string()),
1301            Duration::from_millis(100),
1302        )
1303        .expect("input should arrive before timeout");
1304
1305        assert_eq!(input, "verification-code\n");
1306    }
1307
1308    #[test]
1309    fn test_read_line_with_timeout_enforces_deadline() {
1310        let start = Instant::now();
1311        let result = read_line_with_timeout(
1312            || {
1313                std::thread::sleep(Duration::from_millis(250));
1314                Ok("late input\n".to_string())
1315            },
1316            Duration::from_millis(20),
1317        );
1318
1319        assert!(matches!(result, Err(InputError::Timeout)));
1320        assert!(
1321            start.elapsed() < Duration::from_millis(150),
1322            "timeout should return before the blocking reader finishes"
1323        );
1324    }
1325
1326    #[test]
1327    fn sanitize_strips_csi_sequences() {
1328        // SGR (color), erase-line, cursor position — all CSI variants must
1329        // disappear without consuming surrounding text.
1330        assert_eq!(sanitize_for_display("rm \x1b[31m-rf /\x1b[0m"), "rm -rf /");
1331        assert_eq!(sanitize_for_display("\x1b[Kafter-erase"), "after-erase");
1332        assert_eq!(sanitize_for_display("\x1b[2J\x1b[Hclear"), "clear");
1333    }
1334
1335    #[test]
1336    fn sanitize_strips_osc_sequences() {
1337        // Terminal-title set (OSC 0;...BEL) — the canonical "fake the prompt"
1338        // attack — must be stripped entirely.
1339        assert_eq!(
1340            sanitize_for_display("\x1b]0;Pwned by attacker\x07rm /etc"),
1341            "rm /etc"
1342        );
1343        // OSC 8 hyperlink (ESC \\ terminator).
1344        assert_eq!(
1345            sanitize_for_display("\x1b]8;;https://evil\x1b\\click\x1b]8;;\x1b\\"),
1346            "click"
1347        );
1348    }
1349
1350    #[test]
1351    fn sanitize_visualizes_remaining_control_chars() {
1352        // Newlines and CRs that would break the box / overwrite the prompt
1353        // boundary become visible escapes.
1354        assert_eq!(
1355            sanitize_for_display("line1\nline2\r> fake-prompt"),
1356            "line1\\nline2\\r> fake-prompt"
1357        );
1358        // BEL outside an OSC also gets visualized.
1359        assert_eq!(sanitize_for_display("ding\x07"), "ding\\x07");
1360        // DEL and other low control bytes.
1361        assert_eq!(sanitize_for_display("a\x7fb"), "a\\x7Fb");
1362        assert_eq!(sanitize_for_display("\x00null"), "\\x00null");
1363    }
1364
1365    #[test]
1366    fn sanitize_passes_through_normal_text_and_unicode() {
1367        assert_eq!(sanitize_for_display("rm -rf /tmp/foo"), "rm -rf /tmp/foo");
1368        assert_eq!(
1369            sanitize_for_display("git commit -m \"naïve façade — done\""),
1370            "git commit -m \"naïve façade — done\""
1371        );
1372    }
1373
1374    #[test]
1375    fn sanitize_drops_two_byte_esc_sequences() {
1376        // ESC = (DECPAM) and other 2-byte sequences are conservatively dropped.
1377        assert_eq!(sanitize_for_display("foo\x1b=bar"), "foobar");
1378        // Truncated escape at end-of-string drops cleanly.
1379        assert_eq!(sanitize_for_display("foo\x1b"), "foo");
1380    }
1381
1382    #[test]
1383    fn test_read_line_from_reader_maps_eof_to_interrupted() {
1384        let mut reader = io::Cursor::new(Vec::<u8>::new());
1385
1386        assert!(matches!(
1387            read_line_from_reader(&mut reader),
1388            Err(InputError::Interrupted)
1389        ));
1390    }
1391}