Skip to main content

destructive_command_guard/
evaluator.rs

1//! Shared command evaluator for hook mode and CLI.
2//!
3//! This module provides a unified evaluation entry point that can be used by both
4//! the hook mode (stdin JSON) and CLI (`dcg test`) to ensure consistent behavior.
5//!
6//! # Architecture
7//!
8//! The evaluator performs the following steps in order:
9//!
10//! 1. **Config block overrides** - Explicit block patterns deny before allow patterns
11//! 2. **Config allow overrides** - Explicit allow patterns permit non-blocked commands
12//! 3. **Heredoc/inline scripts** - Extract + AST-scan embedded code (fail-open)
13//! 4. **Quick rejection** - Skip pack evaluation if no relevant keywords present
14//! 5. **Context sanitization** - Mask known-safe string arguments (reduce false positives)
15//! 6. **Command normalization** - Strip absolute paths from git/rm binaries
16//! 7. **Pack registry** - Check enabled packs (safe patterns first, then destructive)
17//!
18//! # Example
19//!
20//! ```ignore
21//! use destructive_command_guard::config::Config;
22//! use destructive_command_guard::evaluator::{evaluate_command, EvaluationDecision};
23//!
24//! let config = Config::load();
25//! let compiled_overrides = config.overrides.compile();
26//! let enabled_keywords = vec!["git", "rm", "docker"];
27//! let allowlists = destructive_command_guard::load_default_allowlists();
28//! let result = evaluate_command(
29//!     "git reset --hard",
30//!     &config,
31//!     &enabled_keywords,
32//!     &compiled_overrides,
33//!     &allowlists,
34//! );
35//!
36//! match result.decision {
37//!     EvaluationDecision::Allow => println!("Command allowed"),
38//!     EvaluationDecision::Deny => {
39//!         if let Some(info) = &result.pattern_info {
40//!             println!("Blocked by {}: {}", info.pack_id.as_deref().unwrap_or("legacy"), info.reason);
41//!         }
42//!     }
43//! }
44//! ```
45
46use crate::allowlist::{AllowlistLayer, LayeredAllowlist};
47use crate::ast_matcher::DEFAULT_MATCHER;
48use crate::config::Config;
49use crate::context::sanitize_for_pattern_matching;
50use crate::heredoc::{
51    ExtractionResult, SkipReason, TriggerResult, check_triggers, extract_content,
52};
53use crate::normalize::{PATH_NORMALIZER, QUOTED_PATH_NORMALIZER, strip_wrapper_prefixes};
54use crate::packs::{
55    PatternSuggestion, REGISTRY, pack_aware_quick_reject, pack_aware_quick_reject_with_normalized,
56};
57use crate::pending_exceptions::AllowOnceStore;
58use crate::perf::Deadline;
59use chrono::Utc;
60use regex::RegexSet;
61use std::borrow::Cow;
62use std::collections::HashSet;
63use std::path::{Path, PathBuf};
64use std::sync::LazyLock;
65
66/// Convert `ast_matcher::Severity` to `packs::Severity`.
67///
68/// Both enums have identical variants; this bridges the two type systems.
69const fn ast_severity_to_pack_severity(s: crate::ast_matcher::Severity) -> crate::packs::Severity {
70    match s {
71        crate::ast_matcher::Severity::Critical => crate::packs::Severity::Critical,
72        crate::ast_matcher::Severity::High => crate::packs::Severity::High,
73        crate::ast_matcher::Severity::Medium => crate::packs::Severity::Medium,
74        crate::ast_matcher::Severity::Low => crate::packs::Severity::Low,
75    }
76}
77
78/// Maximum length for match text preview (in characters, not bytes).
79const MAX_PREVIEW_CHARS: usize = 80;
80
81/// Extract a UTF-8 safe preview of the matched text from a command.
82///
83/// The preview is truncated to `MAX_PREVIEW_CHARS` characters if too long,
84/// with "..." appended to indicate truncation.
85///
86/// If the byte offsets fall in the middle of a multi-byte UTF-8 character,
87/// we snap to the nearest valid character boundary to avoid panics.
88fn extract_match_preview(command: &str, span: &MatchSpan) -> String {
89    // Ensure byte offsets are within bounds
90    let start = span.start.min(command.len());
91    let end = span.end.min(command.len());
92
93    if start >= end {
94        return String::new();
95    }
96
97    // Snap to valid UTF-8 character boundaries to avoid panics.
98    // If start is not at a boundary, move forward to the next boundary.
99    // If end is not at a boundary, move backward to the previous boundary.
100    let safe_start = if command.is_char_boundary(start) {
101        start
102    } else {
103        // Find the next character boundary
104        (start + 1..=command.len())
105            .find(|&i| command.is_char_boundary(i))
106            .unwrap_or(command.len())
107    };
108
109    let safe_end = if command.is_char_boundary(end) {
110        end
111    } else {
112        // Find the previous character boundary
113        (0..end)
114            .rfind(|&i| command.is_char_boundary(i))
115            .unwrap_or(0)
116    };
117
118    if safe_start >= safe_end {
119        return String::new();
120    }
121
122    // Now safe to slice (boundaries are guaranteed valid)
123    let matched = &command[safe_start..safe_end];
124
125    // Truncate to MAX_PREVIEW_CHARS characters (UTF-8 safe)
126    truncate_preview(matched, MAX_PREVIEW_CHARS)
127}
128
129/// Truncate a string to at most `max_chars` characters, UTF-8 safe.
130///
131/// If truncation occurs, appends "..." to indicate more content exists.
132fn truncate_preview(text: &str, max_chars: usize) -> String {
133    let char_count = text.chars().count();
134    if char_count <= max_chars {
135        text.to_string()
136    } else {
137        // Leave room for "..."
138        let truncate_at = max_chars.saturating_sub(3);
139        let truncated: String = text.chars().take(truncate_at).collect();
140        format!("{truncated}...")
141    }
142}
143
144// ============================================================================
145// UTF-8 Safe Windowing for Long Commands
146// ============================================================================
147
148/// Default maximum width for command display (characters, not bytes).
149pub const DEFAULT_WINDOW_WIDTH: usize = 120;
150
151/// Result of windowing a command for display.
152#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct WindowedCommand {
154    /// The windowed command string (with "..." if truncated).
155    pub display: String,
156    /// The span adjusted for the windowed string (for caret alignment).
157    /// None if the original span couldn't be mapped to the window.
158    pub adjusted_span: Option<WindowedSpan>,
159}
160
161/// Span within the windowed command for caret alignment.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct WindowedSpan {
164    /// Start character offset in the windowed display string.
165    pub start: usize,
166    /// End character offset in the windowed display string.
167    pub end: usize,
168}
169
170/// Snap a byte offset to the nearest valid UTF-8 character boundary.
171///
172/// If `prefer_forward` is true, snaps forward; otherwise snaps backward.
173fn snap_to_char_boundary(s: &str, offset: usize, prefer_forward: bool) -> usize {
174    if offset >= s.len() {
175        return s.len();
176    }
177    if s.is_char_boundary(offset) {
178        return offset;
179    }
180    if prefer_forward {
181        (offset + 1..=s.len())
182            .find(|&i| s.is_char_boundary(i))
183            .unwrap_or(s.len())
184    } else {
185        (0..offset).rfind(|&i| s.is_char_boundary(i)).unwrap_or(0)
186    }
187}
188
189/// Create a windowed view of a command centered around a match span.
190///
191/// This function:
192/// - Returns the full command if it fits within `max_width` characters
193/// - Otherwise, centers the window around the match span
194/// - Adds "..." prefix when left-truncating
195/// - Adds "..." suffix when right-truncating
196/// - Ensures all slicing respects UTF-8 character boundaries
197///
198/// # Arguments
199///
200/// * `command` - The full command string
201/// * `span` - The match span (byte offsets) to center around
202/// * `max_width` - Maximum display width in characters (not bytes)
203///
204/// # Returns
205///
206/// A `WindowedCommand` with the display string and adjusted span for caret alignment.
207///
208/// # Example
209///
210/// ```
211/// use destructive_command_guard::evaluator::{window_command, MatchSpan};
212///
213/// let cmd = "very long prefix ... git reset --hard ... more suffix text";
214/// let span = MatchSpan { start: 24, end: 40 }; // "git reset --hard"
215/// let result = window_command(cmd, &span, 40);
216///
217/// // Result shows match in context with ellipsis
218/// assert!(result.display.contains("git reset --hard"));
219/// assert!(result.adjusted_span.is_some());
220/// ```
221#[must_use]
222pub fn window_command(command: &str, span: &MatchSpan, max_width: usize) -> WindowedCommand {
223    let char_count = command.chars().count();
224
225    // If command fits, return as-is with byte-to-char span conversion
226    if char_count <= max_width {
227        let adjusted_span = byte_span_to_char_span(command, span);
228        return WindowedCommand {
229            display: command.to_string(),
230            adjusted_span,
231        };
232    }
233
234    // Snap span to character boundaries
235    let safe_start = snap_to_char_boundary(command, span.start, true);
236    let safe_end = snap_to_char_boundary(command, span.end, false);
237
238    if safe_start >= safe_end || safe_start >= command.len() {
239        // Invalid span - return truncated command without span
240        let truncated: String = command.chars().take(max_width.saturating_sub(3)).collect();
241        return WindowedCommand {
242            display: format!("{truncated}..."),
243            adjusted_span: None,
244        };
245    }
246
247    // Convert byte offsets to character positions for windowing logic
248    let match_char_start = command[..safe_start].chars().count();
249    let match_char_end = command[..safe_end].chars().count();
250    let match_char_len = match_char_end.saturating_sub(match_char_start);
251
252    // Calculate window bounds in character positions
253    // Reserve space for "..." on each side (3 chars each)
254    let ellipsis_len = 3;
255    let available_width = max_width.saturating_sub(ellipsis_len * 2);
256
257    // If match itself is larger than window, show what we can
258    if match_char_len >= available_width {
259        let visible_match: String = command[safe_start..safe_end]
260            .chars()
261            .take(available_width)
262            .collect();
263        return WindowedCommand {
264            display: format!("...{visible_match}..."),
265            adjusted_span: Some(WindowedSpan {
266                start: ellipsis_len,
267                end: ellipsis_len + visible_match.chars().count(),
268            }),
269        };
270    }
271
272    // Calculate context to show around the match
273    let context_budget = available_width.saturating_sub(match_char_len);
274    let left_context = context_budget / 2;
275    let right_context = context_budget - left_context;
276
277    // Determine window start/end in character positions
278    let window_char_start = match_char_start.saturating_sub(left_context);
279    let window_char_end = (match_char_end + right_context).min(char_count);
280
281    // Check if we need ellipsis on each side
282    let needs_left_ellipsis = window_char_start > 0;
283    let needs_right_ellipsis = window_char_end < char_count;
284
285    // Build the windowed string
286    let mut result = String::new();
287    let adjusted_start = if needs_left_ellipsis {
288        result.push_str("...");
289        ellipsis_len
290    } else {
291        0
292    };
293
294    // Extract the windowed portion
295    let windowed: String = command
296        .chars()
297        .skip(window_char_start)
298        .take(window_char_end - window_char_start)
299        .collect();
300
301    // Calculate adjusted span within the windowed result
302    let span_start_in_window = match_char_start - window_char_start + adjusted_start;
303    let span_end_in_window = span_start_in_window + match_char_len;
304
305    result.push_str(&windowed);
306
307    if needs_right_ellipsis {
308        result.push_str("...");
309    }
310
311    WindowedCommand {
312        display: result,
313        adjusted_span: Some(WindowedSpan {
314            start: span_start_in_window,
315            end: span_end_in_window,
316        }),
317    }
318}
319
320/// Convert a byte span to a character span for caret alignment.
321fn byte_span_to_char_span(command: &str, span: &MatchSpan) -> Option<WindowedSpan> {
322    let safe_start = snap_to_char_boundary(command, span.start, true);
323    let safe_end = snap_to_char_boundary(command, span.end, false);
324
325    if safe_start >= safe_end || safe_start >= command.len() {
326        return None;
327    }
328
329    let char_start = command[..safe_start].chars().count();
330    let char_end = command[..safe_end].chars().count();
331
332    Some(WindowedSpan {
333        start: char_start,
334        end: char_end,
335    })
336}
337
338fn compute_normalized_offset(command_for_match: &str, normalized: &str) -> Option<usize> {
339    if normalized == command_for_match {
340        return Some(0);
341    }
342
343    if let Some(pos) = command_for_match.find(normalized) {
344        return Some(pos);
345    }
346
347    let stripped = strip_wrapper_prefixes(command_for_match);
348    let stripped_cmd = stripped.normalized.as_ref();
349    let base_offset = command_for_match.find(stripped_cmd)?;
350
351    if stripped_cmd == normalized {
352        return Some(base_offset);
353    }
354
355    if let Some(pos) = stripped_cmd.find(normalized) {
356        return Some(base_offset + pos);
357    }
358
359    if let Ok(Some(caps)) = QUOTED_PATH_NORMALIZER.captures(stripped_cmd) {
360        if let Some(m) = caps.get(1) {
361            return Some(base_offset + m.start());
362        }
363    }
364
365    if let Ok(Some(caps)) = PATH_NORMALIZER.captures(stripped_cmd) {
366        if let Some(m) = caps.get(1) {
367            return Some(base_offset + m.start());
368        }
369    }
370
371    None
372}
373
374fn map_span_with_offset(
375    span: MatchSpan,
376    offset: Option<usize>,
377    original_len: usize,
378) -> Option<MatchSpan> {
379    let offset = offset?;
380    let start = span.start.saturating_add(offset);
381    let end = span.end.saturating_add(offset);
382    if start <= end && end <= original_len {
383        Some(MatchSpan { start, end })
384    } else {
385        None
386    }
387}
388
389/// The decision made by the evaluator.
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum EvaluationDecision {
392    /// Command is allowed to execute.
393    Allow,
394    /// Command is blocked from executing.
395    Deny,
396}
397
398/// Byte span of a match within the evaluated command string.
399#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub struct MatchSpan {
401    /// Start byte offset (inclusive).
402    pub start: usize,
403    /// End byte offset (exclusive).
404    pub end: usize,
405}
406
407/// Information about the pattern that matched (for denials).
408#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct PatternMatch {
410    /// The pack that blocked the command (None for legacy patterns or config overrides).
411    pub pack_id: Option<String>,
412    /// The name of the pattern that matched (if available).
413    pub pattern_name: Option<String>,
414    /// Severity level of the matched pattern.
415    pub severity: Option<crate::packs::Severity>,
416    /// Human-readable reason for blocking.
417    pub reason: String,
418    /// Source of the match (for debugging/explain mode).
419    pub source: MatchSource,
420    /// Byte span of the first match within the command (for explain highlighting).
421    pub matched_span: Option<MatchSpan>,
422    /// Preview of the matched text (UTF-8 safe, truncated if too long).
423    pub matched_text_preview: Option<String>,
424    /// Detailed explanation of why this pattern is dangerous.
425    /// More verbose than `reason`, intended for explain/verbose output modes.
426    /// Falls back to `reason` when not provided.
427    pub explanation: Option<String>,
428    /// Safer alternative commands suggested for this pattern.
429    pub suggestions: &'static [PatternSuggestion],
430}
431
432/// Information about an allowlist override (DENY -> ALLOW).
433#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct AllowlistOverride {
435    /// Which allowlist layer matched (project/user/system).
436    pub layer: AllowlistLayer,
437    /// The allowlist entry reason (why this override exists).
438    pub reason: String,
439    /// The match that would have denied the command.
440    pub matched: PatternMatch,
441}
442
443/// Source of a pattern match (for debugging and explain mode).
444#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum MatchSource {
446    /// Matched a config override (allow or block).
447    ConfigOverride,
448    /// Matched a legacy pattern in main.rs.
449    LegacyPattern,
450    /// Matched a pattern from a pack.
451    Pack,
452    /// Matched an AST/heuristic pattern in an embedded script (heredoc / inline code).
453    HeredocAst,
454}
455
456/// Git branch context for the evaluation.
457///
458/// Present when git branch awareness is enabled and we're in a git repository.
459#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct BranchContext {
461    /// The current branch name (None if detached HEAD or not in git repo).
462    pub branch_name: Option<String>,
463    /// Whether this is a protected branch.
464    pub is_protected: bool,
465    /// Whether this is a relaxed branch.
466    pub is_relaxed: bool,
467    /// The effective strictness level for this branch.
468    pub strictness: crate::config::StrictnessLevel,
469    /// Whether the decision was affected by branch awareness.
470    /// True if the command would have been blocked but was allowed due to
471    /// relaxed strictness on a non-protected branch.
472    pub affected_decision: bool,
473}
474
475/// Result of evaluating a command.
476#[derive(Debug, Clone)]
477pub struct EvaluationResult {
478    /// The decision (Allow or Deny).
479    pub decision: EvaluationDecision,
480    /// Pattern match information (present when decision is Deny or Warn).
481    pub pattern_info: Option<PatternMatch>,
482    /// Allowlist override information (present when decision is Allow due to allowlist).
483    pub allowlist_override: Option<AllowlistOverride>,
484    /// Effective decision mode (how to handle the decision).
485    /// Present when a pattern matched. None means the command is clean (no pattern matched).
486    /// - Deny: block command, output warning + JSON deny
487    /// - Warn: allow command, output warning only
488    /// - Log: allow command, log only (no visible output)
489    pub effective_mode: Option<crate::packs::DecisionMode>,
490    /// Whether evaluation skipped deeper analysis due to a deadline overrun.
491    pub skipped_due_to_budget: bool,
492    /// Git branch context (present when branch awareness is enabled).
493    pub branch_context: Option<BranchContext>,
494    /// Session occurrence snapshot (present when the command matched a pattern).
495    /// Tracks how many times this command has been seen in the current process.
496    pub session_occurrence: Option<crate::session::OccurrenceSnapshot>,
497    /// Graduated response level (present when graduation system is enabled).
498    pub graduated_response: Option<GraduatedResponse>,
499    /// How a soft block was bypassed (present when bypass occurred).
500    pub bypass_method: Option<BypassMethod>,
501}
502
503impl EvaluationResult {
504    /// Create an "allowed" result.
505    #[inline]
506    #[must_use]
507    pub const fn allowed() -> Self {
508        Self {
509            decision: EvaluationDecision::Allow,
510            pattern_info: None,
511            allowlist_override: None,
512            effective_mode: None,
513            skipped_due_to_budget: false,
514            branch_context: None,
515            session_occurrence: None,
516            graduated_response: None,
517            bypass_method: None,
518        }
519    }
520
521    /// Create an "allowed" result due to budget exhaustion (fail-open).
522    #[inline]
523    #[must_use]
524    pub const fn allowed_due_to_budget() -> Self {
525        Self {
526            decision: EvaluationDecision::Allow,
527            pattern_info: None,
528            allowlist_override: None,
529            effective_mode: None,
530            skipped_due_to_budget: true,
531            branch_context: None,
532            session_occurrence: None,
533            graduated_response: None,
534            bypass_method: None,
535        }
536    }
537
538    /// Create a "denied" result from config override.
539    #[inline]
540    #[must_use]
541    pub const fn denied_by_config(reason: String) -> Self {
542        Self {
543            decision: EvaluationDecision::Deny,
544            pattern_info: Some(PatternMatch {
545                pack_id: None,
546                pattern_name: None,
547                severity: None,
548                reason,
549                source: MatchSource::ConfigOverride,
550                matched_span: None,
551                matched_text_preview: None,
552                explanation: None,
553                suggestions: &[],
554            }),
555            allowlist_override: None,
556            effective_mode: Some(crate::packs::DecisionMode::Deny),
557            skipped_due_to_budget: false,
558            branch_context: None,
559            session_occurrence: None,
560            graduated_response: None,
561            bypass_method: None,
562        }
563    }
564
565    /// Create a "denied" result from legacy pattern.
566    #[inline]
567    #[must_use]
568    pub fn denied_by_legacy(reason: &str) -> Self {
569        Self {
570            decision: EvaluationDecision::Deny,
571            pattern_info: Some(PatternMatch {
572                pack_id: None,
573                pattern_name: None,
574                severity: None,
575                reason: reason.to_string(),
576                source: MatchSource::LegacyPattern,
577                matched_span: None,
578                matched_text_preview: None,
579                explanation: None,
580                suggestions: &[],
581            }),
582            allowlist_override: None,
583            effective_mode: Some(crate::packs::DecisionMode::Deny),
584            skipped_due_to_budget: false,
585            branch_context: None,
586            session_occurrence: None,
587            graduated_response: None,
588            bypass_method: None,
589        }
590    }
591
592    /// Create a "denied" result from legacy pattern with match span.
593    #[inline]
594    #[must_use]
595    pub fn denied_by_legacy_with_span(reason: &str, command: &str, span: MatchSpan) -> Self {
596        let preview = extract_match_preview(command, &span);
597        Self {
598            decision: EvaluationDecision::Deny,
599            pattern_info: Some(PatternMatch {
600                pack_id: None,
601                pattern_name: None,
602                severity: None,
603                reason: reason.to_string(),
604                source: MatchSource::LegacyPattern,
605                matched_span: Some(span),
606                matched_text_preview: Some(preview),
607                explanation: None,
608                suggestions: &[],
609            }),
610            allowlist_override: None,
611            effective_mode: Some(crate::packs::DecisionMode::Deny),
612            skipped_due_to_budget: false,
613            branch_context: None,
614            session_occurrence: None,
615            graduated_response: None,
616            bypass_method: None,
617        }
618    }
619
620    /// Create a "denied" result from a pack.
621    #[inline]
622    #[must_use]
623    pub fn denied_by_pack(pack_id: &str, reason: &str, explanation: Option<&str>) -> Self {
624        Self {
625            decision: EvaluationDecision::Deny,
626            pattern_info: Some(PatternMatch {
627                pack_id: Some(pack_id.to_string()),
628                pattern_name: None,
629                severity: None,
630                reason: reason.to_string(),
631                source: MatchSource::Pack,
632                matched_span: None,
633                matched_text_preview: None,
634                explanation: explanation.map(str::to_string),
635                suggestions: &[],
636            }),
637            allowlist_override: None,
638            effective_mode: Some(crate::packs::DecisionMode::Deny),
639            skipped_due_to_budget: false,
640            branch_context: None,
641            session_occurrence: None,
642            graduated_response: None,
643            bypass_method: None,
644        }
645    }
646
647    /// Create a "denied" result from a pack with match span info.
648    #[inline]
649    #[must_use]
650    pub fn denied_by_pack_with_span(
651        pack_id: &str,
652        reason: &str,
653        explanation: Option<&str>,
654        command: &str,
655        span: MatchSpan,
656    ) -> Self {
657        let preview = extract_match_preview(command, &span);
658        Self {
659            decision: EvaluationDecision::Deny,
660            pattern_info: Some(PatternMatch {
661                pack_id: Some(pack_id.to_string()),
662                pattern_name: None,
663                severity: None,
664                reason: reason.to_string(),
665                source: MatchSource::Pack,
666                matched_span: Some(span),
667                matched_text_preview: Some(preview),
668                explanation: explanation.map(str::to_string),
669                suggestions: &[],
670            }),
671            allowlist_override: None,
672            effective_mode: Some(crate::packs::DecisionMode::Deny),
673            skipped_due_to_budget: false,
674            branch_context: None,
675            session_occurrence: None,
676            graduated_response: None,
677            bypass_method: None,
678        }
679    }
680
681    /// Create a "denied" result from a pack with pattern name.
682    #[inline]
683    #[must_use]
684    pub fn denied_by_pack_pattern(
685        pack_id: &str,
686        pattern_name: &str,
687        reason: &str,
688        explanation: Option<&str>,
689        severity: crate::packs::Severity,
690        suggestions: &'static [PatternSuggestion],
691    ) -> Self {
692        Self {
693            decision: EvaluationDecision::Deny,
694            pattern_info: Some(PatternMatch {
695                pack_id: Some(pack_id.to_string()),
696                pattern_name: Some(pattern_name.to_string()),
697                severity: Some(severity),
698                reason: reason.to_string(),
699                source: MatchSource::Pack,
700                matched_span: None,
701                matched_text_preview: None,
702                explanation: explanation.map(str::to_string),
703                suggestions,
704            }),
705            allowlist_override: None,
706            effective_mode: Some(severity.default_mode()),
707            skipped_due_to_budget: false,
708            branch_context: None,
709            session_occurrence: None,
710            graduated_response: None,
711            bypass_method: None,
712        }
713    }
714
715    /// Create a "denied" result from a pack with pattern name and match span.
716    #[inline]
717    #[must_use]
718    pub fn denied_by_pack_pattern_with_span(
719        pack_id: &str,
720        pattern_name: &str,
721        reason: &str,
722        explanation: Option<&str>,
723        severity: crate::packs::Severity,
724        suggestions: &'static [PatternSuggestion],
725        command: &str,
726        span: MatchSpan,
727    ) -> Self {
728        let preview = extract_match_preview(command, &span);
729        Self {
730            decision: EvaluationDecision::Deny,
731            pattern_info: Some(PatternMatch {
732                pack_id: Some(pack_id.to_string()),
733                pattern_name: Some(pattern_name.to_string()),
734                severity: Some(severity),
735                reason: reason.to_string(),
736                source: MatchSource::Pack,
737                matched_span: Some(span),
738                matched_text_preview: Some(preview),
739                explanation: explanation.map(str::to_string),
740                suggestions,
741            }),
742            allowlist_override: None,
743            effective_mode: Some(severity.default_mode()),
744            skipped_due_to_budget: false,
745            branch_context: None,
746            session_occurrence: None,
747            graduated_response: None,
748            bypass_method: None,
749        }
750    }
751
752    /// Create an "allowed" result due to allowlist override.
753    #[must_use]
754    pub const fn allowed_by_allowlist(
755        matched: PatternMatch,
756        layer: AllowlistLayer,
757        reason: String,
758    ) -> Self {
759        Self {
760            decision: EvaluationDecision::Allow,
761            pattern_info: None,
762            allowlist_override: Some(AllowlistOverride {
763                layer,
764                reason,
765                matched,
766            }),
767            // Allowlist overrides apply to a matched rule (typically deny-by-default).
768            effective_mode: Some(crate::packs::DecisionMode::Deny),
769            skipped_due_to_budget: false,
770            branch_context: None,
771            session_occurrence: None,
772            graduated_response: None,
773            bypass_method: None,
774        }
775    }
776
777    /// Check if the command was allowed.
778    #[inline]
779    #[must_use]
780    pub fn is_allowed(&self) -> bool {
781        self.decision == EvaluationDecision::Allow
782    }
783
784    /// Check if the command was denied.
785    #[inline]
786    #[must_use]
787    pub fn is_denied(&self) -> bool {
788        self.decision == EvaluationDecision::Deny
789    }
790
791    /// Get the reason for denial (if denied).
792    #[must_use]
793    pub fn reason(&self) -> Option<&str> {
794        self.pattern_info.as_ref().map(|p| p.reason.as_str())
795    }
796
797    /// Get the session occurrence count for this command, if tracked.
798    #[inline]
799    #[must_use]
800    pub fn session_count(&self) -> Option<u32> {
801        self.session_occurrence.as_ref().map(|s| s.session_count)
802    }
803
804    /// Get the pack ID that blocked (if denied by a pack).
805    #[must_use]
806    pub fn pack_id(&self) -> Option<&str> {
807        self.pattern_info
808            .as_ref()
809            .and_then(|p| p.pack_id.as_deref())
810    }
811
812    /// Apply graduation logic based on session occurrence data.
813    ///
814    /// If the result has a session occurrence snapshot and a severity, computes
815    /// the graduated response. Does nothing if graduation is disabled or there
816    /// is no occurrence data.
817    pub fn apply_graduation(&mut self, config: &crate::config::ResponseConfig) {
818        self.apply_graduation_with_history_count(None, config);
819    }
820
821    /// Same as [`apply_graduation`] but also feeds an optional cross-session
822    /// `history_count` (occurrences of this command's `command_hash` blocked
823    /// within `config.history_window`) into the graduation computation.
824    /// Standard/Lenient mode escalates based on whichever signal — session
825    /// or history — is stronger. Pass `None` to keep the previous behavior.
826    pub fn apply_graduation_with_history_count(
827        &mut self,
828        history_count: Option<u32>,
829        config: &crate::config::ResponseConfig,
830    ) {
831        if !config.is_enabled() {
832            return;
833        }
834        let session_count = match self.session_occurrence.as_ref() {
835            Some(snap) => snap.session_count,
836            None => return,
837        };
838        let severity = self
839            .pattern_info
840            .as_ref()
841            .and_then(|p| p.severity)
842            .unwrap_or(crate::packs::Severity::High);
843        self.graduated_response = determine_graduated_response_with_history(
844            session_count,
845            history_count,
846            severity,
847            config,
848        );
849    }
850
851    /// Convenience: query the supplied [`HistoryDb`] for the number of
852    /// times this command's `command_hash` was blocked within
853    /// `config.history_window`, then apply graduation. On any history
854    /// query error, falls back to session-only graduation (fail-open) so
855    /// the hot path never errors out.
856    pub fn apply_graduation_with_history_db(
857        &mut self,
858        command: &str,
859        history: &crate::history::HistoryDb,
860        config: &crate::config::ResponseConfig,
861    ) {
862        if !config.is_enabled() {
863            return;
864        }
865        let window = config.history_window_duration();
866        let history_count = match history.count_command_blocks_in_window(command, window) {
867            Ok(n) => Some(n),
868            Err(e) => {
869                tracing::debug!(error = %e, "history count query failed; falling back to session-only graduation");
870                None
871            }
872        };
873        self.apply_graduation_with_history_count(history_count, config);
874    }
875
876    /// Record the command in session tracking and apply graduation.
877    ///
878    /// Convenience method that:
879    /// 1. Records the command occurrence via [`crate::session::record_and_snapshot`].
880    /// 2. Calls [`apply_graduation`](Self::apply_graduation).
881    pub fn record_and_graduate(&mut self, command: &str, config: &crate::config::ResponseConfig) {
882        if self.is_denied() {
883            let snap = crate::session::record_and_snapshot(command);
884            self.session_occurrence = Some(snap);
885            self.apply_graduation(config);
886        }
887    }
888}
889
890/// Response level from the graduation system.
891#[derive(Debug, Clone, PartialEq, Eq)]
892pub enum GraduatedResponse {
893    /// Command seen before but below block threshold.
894    Warning { occurrence: u32 },
895    /// Session threshold reached; agent should reconsider (bypassable).
896    SoftBlock { occurrence: u32 },
897    /// Hard block; too many repeated attempts.
898    HardBlock { total_occurrences: u32 },
899}
900
901impl GraduatedResponse {
902    /// Whether this response blocks the command.
903    #[must_use]
904    pub const fn blocks(&self) -> bool {
905        matches!(self, Self::SoftBlock { .. } | Self::HardBlock { .. })
906    }
907
908    /// Whether this is an unbypassable hard block.
909    #[must_use]
910    pub const fn is_hard_block(&self) -> bool {
911        matches!(self, Self::HardBlock { .. })
912    }
913
914    /// The graduation mode that produced this response.
915    #[must_use]
916    pub fn decision_mode(&self) -> &'static str {
917        match self {
918            Self::Warning { .. } => "warning",
919            Self::SoftBlock { .. } => "soft_block",
920            Self::HardBlock { .. } => "hard_block",
921        }
922    }
923
924    /// Human-friendly label.
925    #[must_use]
926    pub fn label(&self) -> String {
927        match self {
928            Self::Warning { occurrence } => format!("warning (occurrence #{occurrence})"),
929            Self::SoftBlock { occurrence } => format!("soft block (occurrence #{occurrence})"),
930            Self::HardBlock { total_occurrences } => {
931                format!("hard block ({total_occurrences} total occurrences)")
932            }
933        }
934    }
935}
936
937/// How a soft block was bypassed.
938#[derive(Debug, Clone, Copy, PartialEq, Eq)]
939pub enum BypassMethod {
940    /// The `--force` flag was used.
941    Force,
942    /// An allow-once exception was granted.
943    AllowOnce,
944}
945
946impl BypassMethod {
947    /// Human-friendly label.
948    #[must_use]
949    pub const fn label(&self) -> &'static str {
950        match self {
951            Self::Force => "force",
952            Self::AllowOnce => "allow_once",
953        }
954    }
955}
956
957/// Determine the graduated response level from session occurrence count and config.
958///
959/// Uses the effective graduation mode for the given severity to decide thresholds.
960/// Returns `None` when graduation is disabled for this severity.
961///
962/// # Counter scope (important for hook usage)
963///
964/// `session_count` is sourced from [`crate::session::record_and_snapshot`],
965/// which lives in a process-local static. dcg runs as a fresh process per
966/// `Bash` hook invocation, so for hook callers `session_count` is effectively
967/// always `1`. Practical implications by mode:
968///
969/// - `Paranoid` / `WarningOnly`: behave as documented (threshold-free).
970/// - `Strict`: every hook invocation is a `SoftBlock`; `HardBlock` requires
971///   `session_soft_block` repetitions, which only occur in long-lived callers
972///   (`dcg test`, MCP server, repeated CLI evaluations within one process).
973/// - `Standard` / `Lenient`: the `Warning`/`SoftBlock` thresholds escalate
974///   only inside a single process. Cross-invocation escalation is governed
975///   by `history_soft_block` / `history_hard_block` / `history_window` in
976///   [`crate::config::ResponseConfig`], but those fields are not yet
977///   consulted here — wiring them in requires querying the history DB
978///   from the hook hot path and is tracked as future work.
979///
980/// Until history-backed escalation lands, treat `Standard`/`Lenient` as
981/// CLI-/MCP-oriented modes; for shell-hook integrations choose `Paranoid`,
982/// `WarningOnly`, or `Strict` depending on how strict a single occurrence
983/// should be.
984#[must_use]
985pub fn determine_graduated_response(
986    session_count: u32,
987    severity: crate::packs::Severity,
988    config: &crate::config::ResponseConfig,
989) -> Option<GraduatedResponse> {
990    determine_graduated_response_with_history(session_count, None, severity, config)
991}
992
993/// History-aware variant of [`determine_graduated_response`].
994///
995/// Also consults `history_count` (occurrences of this command's
996/// `command_hash` blocked within `config.history_window`). When provided,
997/// Standard/Lenient mode escalates based on whichever signal is louder:
998///
999/// - `history_count >= history_hard_block` → `HardBlock`
1000/// - `history_count >= history_soft_block` → `SoftBlock`
1001/// - otherwise: existing session-only logic
1002///
1003/// Paranoid / WarningOnly / Strict / Disabled are unaffected — they don't
1004/// have escalation tiers driven by occurrence count.
1005///
1006/// Callers without history-DB access pass `None` for `history_count`; the
1007/// behavior matches the pre-wiring evaluator exactly.
1008#[must_use]
1009pub fn determine_graduated_response_with_history(
1010    session_count: u32,
1011    history_count: Option<u32>,
1012    severity: crate::packs::Severity,
1013    config: &crate::config::ResponseConfig,
1014) -> Option<GraduatedResponse> {
1015    use crate::config::GraduationMode;
1016
1017    if !config.is_enabled() {
1018        return None;
1019    }
1020
1021    let mode = config.effective_mode(severity);
1022
1023    // For Standard/Lenient, history thresholds can lift the response above
1024    // what session_count alone would warrant. Compute the history tier first
1025    // so callers see the strictest applicable response.
1026    let history_tier = history_count.and_then(|hc| {
1027        if matches!(mode, GraduationMode::Standard | GraduationMode::Lenient) {
1028            if hc >= config.history_hard_block {
1029                Some(GraduatedResponse::HardBlock {
1030                    total_occurrences: hc,
1031                })
1032            } else if hc >= config.history_soft_block {
1033                Some(GraduatedResponse::SoftBlock { occurrence: hc })
1034            } else {
1035                None
1036            }
1037        } else {
1038            None
1039        }
1040    });
1041
1042    let session_tier = match mode {
1043        GraduationMode::Disabled => None,
1044        GraduationMode::WarningOnly => Some(GraduatedResponse::Warning {
1045            occurrence: session_count,
1046        }),
1047        GraduationMode::Paranoid => {
1048            // Paranoid: always hard block on first occurrence.
1049            Some(GraduatedResponse::HardBlock {
1050                total_occurrences: session_count,
1051            })
1052        }
1053        GraduationMode::Strict => {
1054            // Strict: soft_block from the first occurrence, escalate to
1055            // hard_block once `session_soft_block` is reached. There is no
1056            // Warning level in Strict — every occurrence below the hard-block
1057            // threshold is a SoftBlock so the user sees a deliberate gate.
1058            if session_count >= config.session_soft_block {
1059                Some(GraduatedResponse::HardBlock {
1060                    total_occurrences: session_count,
1061                })
1062            } else {
1063                Some(GraduatedResponse::SoftBlock {
1064                    occurrence: session_count,
1065                })
1066            }
1067        }
1068        GraduationMode::Standard => {
1069            if session_count >= config.session_soft_block {
1070                Some(GraduatedResponse::SoftBlock {
1071                    occurrence: session_count,
1072                })
1073            } else if session_count >= config.session_warning_count {
1074                Some(GraduatedResponse::Warning {
1075                    occurrence: session_count,
1076                })
1077            } else {
1078                None
1079            }
1080        }
1081        GraduationMode::Lenient => {
1082            // Lenient: double the standard thresholds.
1083            let warn_threshold = config.session_warning_count.saturating_mul(2);
1084            let soft_threshold = config.session_soft_block.saturating_mul(2);
1085            if session_count >= soft_threshold {
1086                Some(GraduatedResponse::SoftBlock {
1087                    occurrence: session_count,
1088                })
1089            } else if session_count >= warn_threshold {
1090                Some(GraduatedResponse::Warning {
1091                    occurrence: session_count,
1092                })
1093            } else {
1094                None
1095            }
1096        }
1097    };
1098
1099    // Pick the strictest applicable response: HardBlock > SoftBlock > Warning.
1100    match (history_tier, session_tier) {
1101        (Some(h), Some(s)) => Some(strictest(h, s)),
1102        (Some(h), None) => Some(h),
1103        (None, s) => s,
1104    }
1105}
1106
1107fn strictest(a: GraduatedResponse, b: GraduatedResponse) -> GraduatedResponse {
1108    fn rank(r: &GraduatedResponse) -> u8 {
1109        match r {
1110            GraduatedResponse::Warning { .. } => 1,
1111            GraduatedResponse::SoftBlock { .. } => 2,
1112            GraduatedResponse::HardBlock { .. } => 3,
1113        }
1114    }
1115    if rank(&a) >= rank(&b) { a } else { b }
1116}
1117
1118// =============================================================================
1119// Detailed Evaluation Result (E1-T3: Expose detailed evaluation in evaluator)
1120// =============================================================================
1121
1122/// Detailed evaluation result with timing and diagnostic information.
1123///
1124/// This struct wraps [`EvaluationResult`] with additional metadata useful for
1125/// verbose output, debugging, and the `dcg test` command. It captures timing
1126/// information and which keywords were checked during evaluation.
1127///
1128/// # Example
1129///
1130/// ```ignore
1131/// use destructive_command_guard::evaluator::{evaluate_detailed, DetailedEvaluationResult};
1132/// use destructive_command_guard::config::Config;
1133///
1134/// let config = Config::load();
1135/// let result = evaluate_detailed("git reset --hard", &config);
1136///
1137/// println!("Decision: {:?}", result.result.decision);
1138/// println!("Evaluation time: {}μs", result.evaluation_time_us);
1139/// println!("Keywords checked: {:?}", result.keywords_checked);
1140/// ```
1141#[derive(Debug, Clone)]
1142pub struct DetailedEvaluationResult {
1143    /// The core evaluation result.
1144    pub result: EvaluationResult,
1145    /// Keywords that were checked during evaluation (from enabled packs).
1146    /// Useful for verbose mode to show what the quick-reject filter considered.
1147    pub keywords_checked: Vec<String>,
1148    /// Evaluation duration in microseconds.
1149    pub evaluation_time_us: u64,
1150    /// Confidence scoring result (if confidence scoring was applied).
1151    pub confidence: Option<ConfidenceResult>,
1152    /// The normalized form of the command (after path stripping).
1153    /// Useful for debugging to see what the pattern matcher actually evaluated.
1154    pub normalized_command: Option<String>,
1155    /// Whether quick-reject filtered out this command before pattern matching.
1156    pub quick_rejected: bool,
1157}
1158
1159impl DetailedEvaluationResult {
1160    /// Check if the command was allowed.
1161    #[inline]
1162    #[must_use]
1163    pub fn is_allowed(&self) -> bool {
1164        self.result.is_allowed()
1165    }
1166
1167    /// Check if the command was denied.
1168    #[inline]
1169    #[must_use]
1170    pub fn is_denied(&self) -> bool {
1171        self.result.is_denied()
1172    }
1173
1174    /// Get the core evaluation result.
1175    #[inline]
1176    #[must_use]
1177    pub fn into_result(self) -> EvaluationResult {
1178        self.result
1179    }
1180
1181    /// Get a reference to the core evaluation result.
1182    #[inline]
1183    #[must_use]
1184    pub const fn result(&self) -> &EvaluationResult {
1185        &self.result
1186    }
1187}
1188
1189/// Evaluate a command with detailed timing and diagnostic information.
1190///
1191/// This function wraps [`evaluate_command`] and captures additional metadata
1192/// useful for verbose output, debugging, and the `dcg test` command.
1193///
1194/// # Arguments
1195///
1196/// * `command` - The raw command string to evaluate
1197/// * `config` - Loaded configuration with pack settings
1198///
1199/// # Returns
1200///
1201/// A [`DetailedEvaluationResult`] containing the evaluation result along with
1202/// timing information, keywords checked, and other diagnostic data.
1203///
1204/// # Performance
1205///
1206/// This function has slightly more overhead than [`evaluate_command`] due to
1207/// timing capture and metadata collection. For high-throughput hook mode,
1208/// prefer [`evaluate_command`] or [`evaluate_command_with_pack_order`].
1209///
1210/// # Example
1211///
1212/// ```ignore
1213/// use destructive_command_guard::evaluator::evaluate_detailed;
1214/// use destructive_command_guard::config::Config;
1215///
1216/// let config = Config::load();
1217/// let result = evaluate_detailed("git reset --hard", &config);
1218///
1219/// if result.is_denied() {
1220///     println!("Command blocked in {}μs", result.evaluation_time_us);
1221///     if let Some(info) = &result.result.pattern_info {
1222///         println!("Blocked by: {:?}", info.pack_id);
1223///     }
1224/// }
1225/// ```
1226#[must_use]
1227pub fn evaluate_detailed(command: &str, config: &Config) -> DetailedEvaluationResult {
1228    let allowlists = LayeredAllowlist::default();
1229    evaluate_detailed_with_allowlists(command, config, &allowlists)
1230}
1231
1232/// Evaluate a command with detailed timing and diagnostic information, using custom allowlists.
1233///
1234/// This is the extended version of [`evaluate_detailed`] that accepts custom allowlists.
1235///
1236/// # Arguments
1237///
1238/// * `command` - The raw command string to evaluate
1239/// * `config` - Loaded configuration with pack settings
1240/// * `allowlists` - Layered allowlists (project/user/system)
1241///
1242/// # Returns
1243///
1244/// A [`DetailedEvaluationResult`] containing the evaluation result along with
1245/// timing information, keywords checked, and other diagnostic data.
1246#[must_use]
1247pub fn evaluate_detailed_with_allowlists(
1248    command: &str,
1249    config: &Config,
1250    allowlists: &LayeredAllowlist,
1251) -> DetailedEvaluationResult {
1252    use std::time::Instant;
1253
1254    let start = Instant::now();
1255
1256    // Collect enabled keywords for quick-reject tracking
1257    let enabled_packs = config.enabled_pack_ids();
1258    let enabled_keywords = REGISTRY.collect_enabled_keywords(&enabled_packs);
1259    let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
1260    let keyword_index = REGISTRY.build_enabled_keyword_index(&ordered_packs);
1261    let heredoc_settings = config.heredoc_settings();
1262    let compiled_overrides = config.overrides.compile();
1263
1264    // Track quick-reject status
1265    let quick_rejected = pack_aware_quick_reject(command, &enabled_keywords);
1266
1267    // Get normalized command for diagnostics
1268    let stripped = strip_wrapper_prefixes(command);
1269    let normalized = crate::normalize::normalize_command(stripped.normalized.as_ref());
1270    let normalized_command = if normalized.as_ref() != command {
1271        Some(normalized.into_owned())
1272    } else {
1273        None
1274    };
1275
1276    // Perform evaluation
1277    let result = evaluate_command_with_pack_order(
1278        command,
1279        &enabled_keywords,
1280        &ordered_packs,
1281        keyword_index.as_ref(),
1282        &compiled_overrides,
1283        allowlists,
1284        &heredoc_settings,
1285    );
1286
1287    let evaluation_time_us = start.elapsed().as_micros() as u64;
1288
1289    // Apply confidence scoring if applicable
1290    let confidence = if result.is_denied() {
1291        let sanitized = sanitize_for_pattern_matching(command);
1292        let sanitized_str = if matches!(sanitized, std::borrow::Cow::Owned(_)) {
1293            Some(sanitized.as_ref())
1294        } else {
1295            None
1296        };
1297        let mode = result
1298            .effective_mode
1299            .unwrap_or(crate::packs::DecisionMode::Deny);
1300        Some(apply_confidence_scoring(
1301            command,
1302            sanitized_str,
1303            &result,
1304            mode,
1305            &config.confidence,
1306        ))
1307    } else {
1308        None
1309    };
1310
1311    DetailedEvaluationResult {
1312        result,
1313        keywords_checked: enabled_keywords.iter().map(|s| (*s).to_string()).collect(),
1314        evaluation_time_us,
1315        confidence,
1316        normalized_command,
1317        quick_rejected,
1318    }
1319}
1320
1321/// Evaluate a command against all patterns and packs using precompiled overrides.
1322///
1323/// This is the main entry point for command evaluation. It performs all checks
1324/// in the correct order and returns a structured result.
1325///
1326/// # Arguments
1327///
1328/// * `command` - The raw command string to evaluate
1329/// * `config` - Loaded configuration with pack settings
1330/// * `enabled_keywords` - Keywords from enabled packs for quick rejection
1331/// * `compiled_overrides` - Precompiled config overrides (avoids per-command regex compilation)
1332///
1333/// # Returns
1334///
1335/// An `EvaluationResult` indicating whether the command is allowed or denied,
1336/// with detailed pattern match information for denials.
1337///
1338/// # Performance
1339///
1340/// This function is optimized for the common case (allow):
1341/// - Quick rejection skips regex for 99%+ of commands
1342/// - Config overrides use precompiled regexes (no per-command compilation)
1343/// - Short-circuits on first match
1344#[must_use]
1345pub fn evaluate_command(
1346    command: &str,
1347    config: &Config,
1348    enabled_keywords: &[&str],
1349    compiled_overrides: &crate::config::CompiledOverrides,
1350    allowlists: &LayeredAllowlist,
1351) -> EvaluationResult {
1352    evaluate_command_with_deadline(
1353        command,
1354        config,
1355        enabled_keywords,
1356        compiled_overrides,
1357        allowlists,
1358        None,
1359    )
1360}
1361
1362#[inline]
1363fn deadline_exceeded(deadline: Option<&Deadline>) -> bool {
1364    deadline.is_some_and(Deadline::is_exceeded)
1365}
1366
1367#[inline]
1368fn contains_shell_word_obfuscation(command: &str) -> bool {
1369    command
1370        .as_bytes()
1371        .iter()
1372        .any(|b| matches!(b, b'\\' | b'\'' | b'"'))
1373}
1374
1375#[inline]
1376fn remaining_below(deadline: Option<&Deadline>, budget: &crate::perf::Budget) -> bool {
1377    deadline.is_some_and(|d| !d.has_budget_for(budget))
1378}
1379
1380fn resolve_project_path(
1381    heredoc_settings: &crate::config::HeredocSettings,
1382    project_path: Option<&Path>,
1383) -> Option<PathBuf> {
1384    if heredoc_settings
1385        .content_allowlist
1386        .as_ref()
1387        .is_none_or(|a| a.projects.is_empty())
1388    {
1389        return None;
1390    }
1391
1392    if let Some(path) = project_path {
1393        return Some(path.to_path_buf());
1394    }
1395
1396    std::env::current_dir().ok()
1397}
1398
1399fn allow_once_match(
1400    command: &str,
1401    allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
1402) -> Option<crate::pending_exceptions::AllowOnceEntry> {
1403    let cwd = std::env::current_dir().ok()?;
1404    let store = AllowOnceStore::new(AllowOnceStore::default_path(Some(&cwd)));
1405    match store.match_command(command, &cwd, Utc::now(), allow_once_audit) {
1406        Ok(Some(entry)) => Some(entry),
1407        _ => None,
1408    }
1409}
1410
1411#[allow(dead_code)]
1412fn allow_once_match_force_config(
1413    command: &str,
1414    allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
1415) -> Option<crate::pending_exceptions::AllowOnceEntry> {
1416    let cwd = std::env::current_dir().ok()?;
1417    let store = AllowOnceStore::new(AllowOnceStore::default_path(Some(&cwd)));
1418    match store.match_command_force_config(command, &cwd, Utc::now(), allow_once_audit) {
1419        Ok(Some(entry)) => Some(entry),
1420        _ => None,
1421    }
1422}
1423
1424/// Evaluate a command against all patterns and packs using a deadline.
1425///
1426/// When `deadline` is provided and exceeded, evaluation fails open and returns
1427/// `skipped_due_to_budget=true` so hook mode can allow the command safely.
1428#[must_use]
1429pub fn evaluate_command_with_deadline(
1430    command: &str,
1431    config: &Config,
1432    enabled_keywords: &[&str],
1433    compiled_overrides: &crate::config::CompiledOverrides,
1434    allowlists: &LayeredAllowlist,
1435    deadline: Option<&Deadline>,
1436) -> EvaluationResult {
1437    let enabled_packs: HashSet<String> = config.enabled_pack_ids();
1438    let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
1439    let keyword_index = REGISTRY.build_enabled_keyword_index(&ordered_packs);
1440    let heredoc_settings = config.heredoc_settings();
1441    evaluate_command_with_pack_order_deadline(
1442        command,
1443        enabled_keywords,
1444        &ordered_packs,
1445        keyword_index.as_ref(),
1446        compiled_overrides,
1447        allowlists,
1448        &heredoc_settings,
1449        None,
1450        deadline,
1451    )
1452}
1453
1454/// Evaluate a command using a precomputed pack order.
1455///
1456/// This is the hot-path optimized variant for hook mode: callers can compute the
1457/// enabled pack set and expanded ordered pack list once at startup and reuse it
1458/// for every command invocation.
1459///
1460/// # Arguments
1461///
1462/// * `command` - The raw command string to evaluate
1463/// * `enabled_keywords` - Keywords from enabled packs for quick rejection
1464/// * `ordered_packs` - Expanded pack IDs in deterministic evaluation order
1465/// * `compiled_overrides` - Precompiled config overrides
1466/// * `allowlists` - Layered allowlists (project/user/system)
1467#[must_use]
1468pub fn evaluate_command_with_pack_order(
1469    command: &str,
1470    enabled_keywords: &[&str],
1471    ordered_packs: &[String],
1472    keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
1473    compiled_overrides: &crate::config::CompiledOverrides,
1474    allowlists: &LayeredAllowlist,
1475    heredoc_settings: &crate::config::HeredocSettings,
1476) -> EvaluationResult {
1477    evaluate_command_with_pack_order_at_path(
1478        command,
1479        enabled_keywords,
1480        ordered_packs,
1481        keyword_index,
1482        compiled_overrides,
1483        allowlists,
1484        heredoc_settings,
1485        None,
1486    )
1487}
1488
1489/// Evaluate a command using a precomputed pack order and an optional project path.
1490#[must_use]
1491#[allow(clippy::too_many_arguments)]
1492pub fn evaluate_command_with_pack_order_at_path(
1493    command: &str,
1494    enabled_keywords: &[&str],
1495    ordered_packs: &[String],
1496    keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
1497    compiled_overrides: &crate::config::CompiledOverrides,
1498    allowlists: &LayeredAllowlist,
1499    heredoc_settings: &crate::config::HeredocSettings,
1500    project_path: Option<&Path>,
1501) -> EvaluationResult {
1502    evaluate_command_with_pack_order_deadline_at_path(
1503        command,
1504        enabled_keywords,
1505        ordered_packs,
1506        keyword_index,
1507        compiled_overrides,
1508        allowlists,
1509        heredoc_settings,
1510        None,
1511        project_path,
1512        None,
1513    )
1514}
1515
1516/// Evaluate a command with deadline support for fail-open behavior.
1517///
1518/// This is the hook-mode entry point that supports budget enforcement.
1519/// If the deadline is exceeded at check points, returns `allowed_due_to_budget()`.
1520///
1521/// # Arguments
1522///
1523/// * `command` - The raw command string to evaluate
1524/// * `enabled_keywords` - Keywords from enabled packs for quick rejection
1525/// * `ordered_packs` - Ordered list of enabled pack IDs
1526/// * `compiled_overrides` - Precompiled config overrides
1527/// * `allowlists` - Layered allowlist for overrides
1528/// * `heredoc_settings` - Settings for heredoc analysis
1529/// * `deadline` - Optional deadline for fail-open behavior
1530///
1531/// # Returns
1532///
1533/// An `EvaluationResult` with `skipped_due_to_budget: true` if deadline exceeded.
1534#[must_use]
1535#[allow(clippy::too_many_arguments)]
1536pub fn evaluate_command_with_pack_order_deadline(
1537    command: &str,
1538    enabled_keywords: &[&str],
1539    ordered_packs: &[String],
1540    keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
1541    compiled_overrides: &crate::config::CompiledOverrides,
1542    allowlists: &LayeredAllowlist,
1543    heredoc_settings: &crate::config::HeredocSettings,
1544    allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
1545    deadline: Option<&Deadline>,
1546) -> EvaluationResult {
1547    evaluate_command_with_pack_order_deadline_at_path(
1548        command,
1549        enabled_keywords,
1550        ordered_packs,
1551        keyword_index,
1552        compiled_overrides,
1553        allowlists,
1554        heredoc_settings,
1555        allow_once_audit,
1556        None,
1557        deadline,
1558    )
1559}
1560
1561/// Evaluate a command with deadline support and an optional project path.
1562#[must_use]
1563#[allow(clippy::too_many_arguments)]
1564#[allow(clippy::too_many_lines)]
1565pub fn evaluate_command_with_pack_order_deadline_at_path(
1566    command: &str,
1567    enabled_keywords: &[&str],
1568    ordered_packs: &[String],
1569    keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
1570    compiled_overrides: &crate::config::CompiledOverrides,
1571    allowlists: &LayeredAllowlist,
1572    heredoc_settings: &crate::config::HeredocSettings,
1573    allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
1574    project_path: Option<&Path>,
1575    deadline: Option<&Deadline>,
1576) -> EvaluationResult {
1577    // Check deadline at entry - if already exceeded, fail-open immediately.
1578    if deadline_exceeded(deadline) {
1579        return EvaluationResult::allowed_due_to_budget();
1580    }
1581
1582    // Empty commands are allowed (no-op)
1583    if command.is_empty() {
1584        return EvaluationResult::allowed();
1585    }
1586
1587    // Step 1: Check precompiled block overrides first. Deny wins when
1588    // allow/block override patterns overlap; only a force allow-once exception
1589    // may intentionally bypass an explicit config block.
1590    if let Some(reason) = compiled_overrides.check_block(command) {
1591        if allow_once_match_force_config(command, allow_once_audit).is_some() {
1592            return EvaluationResult::allowed();
1593        }
1594        return EvaluationResult::denied_by_config(reason.to_string());
1595    }
1596
1597    // Step 1.5: Check precompiled allow overrides after blocks.
1598    if compiled_overrides.check_allow(command) {
1599        return EvaluationResult::allowed();
1600    }
1601
1602    if deadline_exceeded(deadline) {
1603        return EvaluationResult::allowed_due_to_budget();
1604    }
1605
1606    // Step 3: Heredoc / inline-script detection (Tier 1/2/3, fail-open).
1607    let mut precomputed_sanitized = None;
1608    let mut heredoc_allowlist_hit: Option<(PatternMatch, AllowlistLayer, String)> = None;
1609
1610    let project_path = resolve_project_path(heredoc_settings, project_path);
1611    let project_path = project_path.as_deref();
1612
1613    if heredoc_settings.enabled {
1614        if remaining_below(deadline, &crate::perf::HEREDOC_TRIGGER) {
1615            return EvaluationResult::allowed_due_to_budget();
1616        }
1617
1618        if check_triggers(command) == TriggerResult::Triggered {
1619            let sanitized = sanitize_for_pattern_matching(command);
1620            let sanitized_str = sanitized.as_ref();
1621            let should_scan = if matches!(sanitized, std::borrow::Cow::Owned(_)) {
1622                check_triggers(sanitized_str) == TriggerResult::Triggered
1623            } else {
1624                true
1625            };
1626            precomputed_sanitized = Some(sanitized);
1627
1628            if should_scan {
1629                let context = HeredocEvaluationContext {
1630                    allowlists,
1631                    heredoc_settings,
1632                    project_path,
1633                    deadline,
1634                    enabled_keywords,
1635                    ordered_packs,
1636                    keyword_index,
1637                    compiled_overrides,
1638                    allow_once_audit,
1639                };
1640                if let Some(blocked) =
1641                    evaluate_heredoc(command, context, &mut heredoc_allowlist_hit)
1642                {
1643                    return blocked;
1644                }
1645            }
1646        }
1647    }
1648
1649    if deadline_exceeded(deadline) {
1650        return EvaluationResult::allowed_due_to_budget();
1651    }
1652
1653    // Step 4: Quick rejection - if no relevant keywords, allow immediately.
1654    //
1655    // Fast path: when an Aho-Corasick keyword index is available, a single-pass
1656    // AC scan (O(n)) replaces the N×memmem per-keyword scan. If the AC says no
1657    // keyword appears in the raw command, we can skip the more expensive
1658    // normalize+span-classify path in pack_aware_quick_reject entirely.
1659    if let Some(index) = keyword_index {
1660        if !index.has_any_keyword(command) && !contains_shell_word_obfuscation(command) {
1661            if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
1662                return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
1663            }
1664            return EvaluationResult::allowed();
1665        }
1666    } else if pack_aware_quick_reject(command, enabled_keywords) {
1667        if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
1668            return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
1669        }
1670        return EvaluationResult::allowed();
1671    }
1672
1673    if deadline_exceeded(deadline) {
1674        return EvaluationResult::allowed_due_to_budget();
1675    }
1676
1677    // Step 5: False-positive immunity - strip known-safe string arguments (commit messages, search
1678    // patterns, issue descriptions, etc.) so dangerous substrings inside data do not trigger
1679    // blocking.
1680    //
1681    // Also normalize the command here (Step 6) and reuse for pack evaluation.
1682    // pack_aware_quick_reject_with_normalized returns both the quick-reject decision
1683    // and the normalized command, avoiding duplicate normalization.
1684    let sanitized = precomputed_sanitized.unwrap_or_else(|| sanitize_for_pattern_matching(command));
1685    let command_for_match = sanitized.as_ref();
1686
1687    // Use the optimized version that returns both decision and normalized form.
1688    let (quick_reject, normalized) =
1689        pack_aware_quick_reject_with_normalized(command_for_match, enabled_keywords);
1690    if quick_reject
1691        && !should_check_original_control_plane_payload_for_any_pack(
1692            command_for_match,
1693            command,
1694            ordered_packs,
1695        )
1696    {
1697        if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
1698            return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
1699        }
1700        return EvaluationResult::allowed();
1701    }
1702
1703    if deadline_exceeded(deadline) {
1704        return EvaluationResult::allowed_due_to_budget();
1705    }
1706
1707    // Deferred allow-once check: moved here from before keyword quick-reject.
1708    // Allow-once entries only exist for previously blocked commands, which must
1709    // have matched keywords — so deferring past quick-reject is safe and avoids
1710    // ~65µs of filesystem I/O on every unrelated command.
1711    if allow_once_match(command, allow_once_audit).is_some() {
1712        return EvaluationResult::allowed();
1713    }
1714
1715    // Check exact command, prefix, and pattern allowlists (reusing normalized
1716    // from quick-reject). Use path-aware matching for context-aware
1717    // allowlisting (Epic 5). Pattern entries must additionally have
1718    // `risk_acknowledged = true` (enforced inside the matcher's validity check).
1719    if allowlists
1720        .match_exact_command_at_path(&normalized, project_path)
1721        .is_some()
1722        || allowlists
1723            .match_command_prefix_at_path(&normalized, project_path)
1724            .is_some()
1725        || allowlists
1726            .match_pattern_at_path(&normalized, project_path)
1727            .is_some()
1728    {
1729        return EvaluationResult::allowed();
1730    }
1731
1732    // Step 7: Mask heredoc content for non-executing targets (cat, tee, etc.)
1733    // This prevents false positives where documentation text containing dangerous
1734    // patterns like "rm -rf /" in heredocs to cat/tee triggers blocking.
1735    let masked = crate::heredoc::mask_non_executing_heredocs(&normalized);
1736    let command_for_packs = masked.as_ref();
1737
1738    let result = evaluate_packs_with_allowlists(
1739        command_for_packs,
1740        &normalized,
1741        command_for_match,
1742        command,
1743        ordered_packs,
1744        allowlists,
1745        keyword_index,
1746        None,
1747        project_path,
1748    );
1749    if result.allowlist_override.is_none() {
1750        if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
1751            return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
1752        }
1753    }
1754
1755    result
1756}
1757
1758#[allow(clippy::too_many_lines)]
1759#[allow(clippy::too_many_arguments)]
1760fn evaluate_packs_with_allowlists(
1761    command_for_packs: &str,
1762    normalized: &str,
1763    command_for_match: &str,
1764    original_command: &str,
1765    ordered_packs: &[String],
1766    allowlists: &LayeredAllowlist,
1767    keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
1768    deadline: Option<&Deadline>,
1769    project_path: Option<&Path>,
1770) -> EvaluationResult {
1771    if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH) {
1772        return EvaluationResult::allowed_due_to_budget();
1773    }
1774
1775    // Pre-compute which packs might match.
1776    //
1777    // When a keyword index is available, use a single global substring scan to
1778    // conservatively select candidate packs (superset of legacy PackEntry::might_match).
1779    // Otherwise, fall back to the per-pack metadata scan.
1780    //
1781    // External packs from custom_paths are also checked alongside built-in packs.
1782    let external_store = crate::packs::get_external_packs();
1783    let candidate_packs: Vec<(&String, &crate::packs::Pack)> = keyword_index.map_or_else(
1784        || {
1785            ordered_packs
1786                .iter()
1787                .filter_map(|pack_id| {
1788                    // Try built-in registry first
1789                    if let Some(entry) = REGISTRY.get_entry(pack_id) {
1790                        if !entry.might_match(command_for_packs)
1791                            && !should_check_original_control_plane_payload(
1792                                pack_id,
1793                                command_for_packs,
1794                                original_command,
1795                            )
1796                        {
1797                            return None;
1798                        }
1799                        return Some((pack_id, entry.get_pack()));
1800                    }
1801                    // Fallback to external packs
1802                    if let Some(store) = external_store {
1803                        if let Some(pack) = store.get(pack_id) {
1804                            if !pack.might_match(command_for_packs)
1805                                && !should_check_original_control_plane_payload(
1806                                    pack_id,
1807                                    command_for_packs,
1808                                    original_command,
1809                                )
1810                            {
1811                                return None;
1812                            }
1813                            return Some((pack_id, pack));
1814                        }
1815                    }
1816                    None
1817                })
1818                .collect()
1819        },
1820        |index| {
1821            let mask = index.candidate_pack_mask(command_for_packs);
1822            ordered_packs
1823                .iter()
1824                .enumerate()
1825                .filter_map(|(i, pack_id)| {
1826                    if (mask >> i) & 1 == 0
1827                        && !should_check_original_control_plane_payload(
1828                            pack_id,
1829                            command_for_packs,
1830                            original_command,
1831                        )
1832                    {
1833                        return None;
1834                    }
1835                    // Try built-in registry first
1836                    if let Some(entry) = REGISTRY.get_entry(pack_id) {
1837                        return Some((pack_id, entry.get_pack()));
1838                    }
1839                    // Fallback to external packs
1840                    if let Some(store) = external_store {
1841                        if let Some(pack) = store.get(pack_id) {
1842                            return Some((pack_id, pack));
1843                        }
1844                    }
1845                    None
1846                })
1847                .collect()
1848        },
1849    );
1850
1851    let has_filesystem_pack = candidate_packs
1852        .iter()
1853        .any(|(pack_id, _)| pack_id.as_str() == "core.filesystem");
1854    let rm_parse = has_filesystem_pack
1855        .then(|| crate::packs::core::filesystem::parse_rm_command(command_for_packs));
1856
1857    let normalized_offset = compute_normalized_offset(command_for_match, normalized);
1858    let original_len = original_command.len();
1859    let segment_ranges = command_segment_ranges(command_for_packs);
1860    let has_compound_segments = segment_ranges.len() > 1;
1861
1862    // Single-pass per-pack evaluation: safe patterns only protect their own pack's
1863    // destructive patterns, not other packs. This prevents compound command bypass
1864    // where e.g., "git checkout -b foo" safe pattern would whitelist "rm -rf / ; git checkout -b foo".
1865    //
1866    // For each pack:
1867    // 1. Check safe patterns - if match, skip this pack's destructive patterns (continue)
1868    // 2. Check destructive patterns - if match, block (unless allowlisted)
1869    //
1870    // The rm_parse optimization for core.filesystem is handled inline.
1871    let mut first_allowlist_hit: Option<(PatternMatch, AllowlistLayer, String)> = None;
1872
1873    for &(pack_id, pack) in &candidate_packs {
1874        if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH) {
1875            return EvaluationResult::allowed_due_to_budget();
1876        }
1877
1878        // Check safe patterns for this pack first.
1879        // If a safe pattern matches, skip this pack's destructive patterns only.
1880        // This prevents compound command bypass where one pack's safe pattern
1881        // would whitelist destructive commands from other packs.
1882        if pack_id == "core.filesystem" {
1883            let has_pre_rm_propagation_match = pack.destructive_patterns.iter().any(|pattern| {
1884                crate::packs::core::filesystem::is_pre_rm_propagation_rule(pattern.name)
1885                    && pattern.regex.is_match(command_for_packs)
1886            });
1887
1888            // core.filesystem uses rm_parse for more accurate safe pattern detection
1889            match rm_parse.as_ref() {
1890                Some(crate::packs::core::filesystem::RmParseDecision::Allow)
1891                    if !has_pre_rm_propagation_match =>
1892                {
1893                    continue; // Safe pattern match - skip this pack
1894                }
1895                Some(crate::packs::core::filesystem::RmParseDecision::Allow) => {
1896                    // A sensitive-source propagation chain matched before the rm
1897                    // fast path. Fall through to the ordinary destructive-pattern
1898                    // loop so allowlists, spans, explanations, and suggestions are
1899                    // handled consistently.
1900                }
1901                Some(crate::packs::core::filesystem::RmParseDecision::NoMatch) | None => {
1902                    // rm_parse didn't find rm command or wasn't computed, check safe patterns as fallback
1903                    if pack.matches_safe_with_deadline(command_for_packs, deadline) {
1904                        continue;
1905                    }
1906                }
1907                Some(crate::packs::core::filesystem::RmParseDecision::Deny(hit)) => {
1908                    if let Some(allow_hit) =
1909                        allowlists.match_rule_at_path(pack_id, hit.pattern_name, project_path)
1910                    {
1911                        if first_allowlist_hit.is_none() {
1912                            let span = hit.span.as_ref().map(|span| MatchSpan {
1913                                start: span.start,
1914                                end: span.end,
1915                            });
1916                            let mapped_span = span.and_then(|span| {
1917                                map_span_with_offset(span, normalized_offset, original_len)
1918                            });
1919                            let preview = mapped_span
1920                                .as_ref()
1921                                .map(|span| extract_match_preview(original_command, span))
1922                                .or_else(|| {
1923                                    span.as_ref()
1924                                        .map(|span| extract_match_preview(command_for_packs, span))
1925                                });
1926                            first_allowlist_hit = Some((
1927                                PatternMatch {
1928                                    pack_id: Some(pack_id.clone()),
1929                                    pattern_name: Some(hit.pattern_name.to_string()),
1930                                    severity: Some(hit.severity),
1931                                    reason: hit.reason.to_string(),
1932                                    source: MatchSource::Pack,
1933                                    matched_span: mapped_span,
1934                                    matched_text_preview: preview,
1935                                    explanation: None,
1936                                    suggestions: &[],
1937                                },
1938                                allow_hit.layer,
1939                                allow_hit.entry.reason.clone(),
1940                            ));
1941                        }
1942                        continue;
1943                    }
1944
1945                    if let Some(span) = hit.span.as_ref().map(|span| MatchSpan {
1946                        start: span.start,
1947                        end: span.end,
1948                    }) {
1949                        if let Some(mapped_span) =
1950                            map_span_with_offset(span, normalized_offset, original_len)
1951                        {
1952                            return EvaluationResult::denied_by_pack_pattern_with_span(
1953                                pack_id,
1954                                hit.pattern_name,
1955                                hit.reason,
1956                                None,
1957                                hit.severity,
1958                                &[], // fast_match path doesn't have suggestions
1959                                original_command,
1960                                mapped_span,
1961                            );
1962                        }
1963                    }
1964
1965                    return EvaluationResult::denied_by_pack_pattern(
1966                        pack_id,
1967                        hit.pattern_name,
1968                        hit.reason,
1969                        None,
1970                        hit.severity,
1971                        &[], // fast_match path doesn't have suggestions
1972                    );
1973                }
1974            }
1975        } else if has_compound_segments {
1976            for &(segment_start, segment_end) in &segment_ranges {
1977                if deadline_exceeded(deadline)
1978                    || remaining_below(deadline, &crate::perf::PATTERN_MATCH)
1979                {
1980                    return EvaluationResult::allowed_due_to_budget();
1981                }
1982
1983                let segment = &command_for_packs[segment_start..segment_end];
1984                let sanitized_segment = sanitize_for_pattern_matching(segment);
1985                let segment_for_match = sanitized_segment.as_ref();
1986
1987                if pack.matches_safe_with_deadline(segment_for_match, deadline) {
1988                    continue;
1989                }
1990
1991                let nested_segment_ranges: Vec<(usize, usize)> = segment_ranges
1992                    .iter()
1993                    .copied()
1994                    .filter(|&(nested_start, nested_end)| {
1995                        nested_start >= segment_start
1996                            && nested_end <= segment_end
1997                            && !(nested_start == segment_start && nested_end == segment_end)
1998                    })
1999                    .collect();
2000
2001                if let Some(result) = evaluate_pack_destructive_patterns(
2002                    pack_id,
2003                    pack,
2004                    segment_for_match,
2005                    segment_start,
2006                    original_command,
2007                    normalized_offset,
2008                    original_len,
2009                    allowlists,
2010                    project_path,
2011                    &mut first_allowlist_hit,
2012                    deadline,
2013                    &nested_segment_ranges,
2014                ) {
2015                    return result;
2016                }
2017            }
2018        } else if pack.matches_safe_with_deadline(command_for_packs, deadline) {
2019            continue; // Safe pattern match - skip this pack's destructive patterns
2020        }
2021
2022        for pattern in &pack.destructive_patterns {
2023            if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH)
2024            {
2025                return EvaluationResult::allowed_due_to_budget();
2026            }
2027
2028            // All severity levels are now evaluated. The policy layer in main.rs
2029            // determines whether to deny, warn, or log based on severity and config.
2030
2031            let matched_span = pattern
2032                .regex
2033                .find(command_for_packs)
2034                .map(|(start, end)| MatchSpan { start, end });
2035
2036            if deadline_exceeded(deadline) {
2037                return EvaluationResult::allowed_due_to_budget();
2038            }
2039
2040            let Some(span) = matched_span else {
2041                continue;
2042            };
2043
2044            // Non-filesystem packs already checked each segment above, so skip
2045            // duplicate full-command matches that sit wholly inside one segment.
2046            // core.filesystem uses its specialized rm parser instead of that
2047            // segment loop; keep its full-command regex fallback visible.
2048            if has_compound_segments
2049                && pack_id != "core.filesystem"
2050                && span_is_inside_any_segment(span, &segment_ranges)
2051            {
2052                continue;
2053            }
2054
2055            let reason = pattern.reason;
2056            let mapped_span = map_span_with_offset(span, normalized_offset, original_len);
2057            let preview = mapped_span
2058                .as_ref()
2059                .map(|span| extract_match_preview(original_command, span))
2060                .or_else(|| Some(extract_match_preview(command_for_packs, &span)));
2061
2062            // Allowlist check: only applies when we have a stable match identity (named pattern).
2063            if let Some(pattern_name) = pattern.name {
2064                if let Some(hit) =
2065                    allowlists.match_rule_at_path(pack_id, pattern_name, project_path)
2066                {
2067                    if first_allowlist_hit.is_none() {
2068                        first_allowlist_hit = Some((
2069                            PatternMatch {
2070                                pack_id: Some(pack_id.clone()),
2071                                pattern_name: Some(pattern_name.to_string()),
2072                                severity: Some(pattern.severity),
2073                                reason: reason.to_string(),
2074                                source: MatchSource::Pack,
2075                                matched_span: mapped_span,
2076                                matched_text_preview: preview,
2077                                explanation: pattern.explanation.map(str::to_string),
2078                                suggestions: pattern.suggestions,
2079                            },
2080                            hit.layer,
2081                            hit.entry.reason.clone(),
2082                        ));
2083                    }
2084
2085                    // Bypass only this rule and keep evaluating other rules/packs.
2086                    continue;
2087                }
2088
2089                if let Some(mapped_span) = mapped_span {
2090                    return EvaluationResult::denied_by_pack_pattern_with_span(
2091                        pack_id,
2092                        pattern_name,
2093                        reason,
2094                        pattern.explanation,
2095                        pattern.severity,
2096                        pattern.suggestions,
2097                        original_command,
2098                        mapped_span,
2099                    );
2100                }
2101
2102                return EvaluationResult::denied_by_pack_pattern(
2103                    pack_id,
2104                    pattern_name,
2105                    reason,
2106                    pattern.explanation,
2107                    pattern.severity,
2108                    pattern.suggestions,
2109                );
2110            }
2111
2112            if let Some(mapped_span) = mapped_span {
2113                return EvaluationResult::denied_by_pack_with_span(
2114                    pack_id,
2115                    reason,
2116                    pattern.explanation,
2117                    original_command,
2118                    mapped_span,
2119                );
2120            }
2121
2122            return EvaluationResult::denied_by_pack(pack_id, reason, pattern.explanation);
2123        }
2124
2125        if let Some(result) = evaluate_original_control_plane_payloads(
2126            pack_id.as_str(),
2127            pack,
2128            command_for_packs,
2129            original_command,
2130            allowlists,
2131            project_path,
2132            &mut first_allowlist_hit,
2133            deadline,
2134        ) {
2135            return result;
2136        }
2137    }
2138
2139    if let Some((matched, layer, reason)) = first_allowlist_hit {
2140        return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
2141    }
2142
2143    EvaluationResult::allowed()
2144}
2145
2146#[allow(clippy::too_many_arguments)]
2147fn evaluate_original_control_plane_payloads(
2148    pack_id: &str,
2149    pack: &crate::packs::Pack,
2150    command_for_packs: &str,
2151    original_command: &str,
2152    allowlists: &LayeredAllowlist,
2153    project_path: Option<&Path>,
2154    first_allowlist_hit: &mut Option<(PatternMatch, AllowlistLayer, String)>,
2155    deadline: Option<&Deadline>,
2156) -> Option<EvaluationResult> {
2157    if !should_check_original_control_plane_payload(pack_id, command_for_packs, original_command) {
2158        return None;
2159    }
2160
2161    let original_len = original_command.len();
2162    let segment_ranges = command_segment_ranges(original_command);
2163    if segment_ranges.len() <= 1 {
2164        let command_slice = control_plane_segment_for_matching(original_command);
2165        return evaluate_pack_destructive_patterns(
2166            pack_id,
2167            pack,
2168            command_slice.as_ref(),
2169            0,
2170            original_command,
2171            Some(0),
2172            original_len,
2173            allowlists,
2174            project_path,
2175            first_allowlist_hit,
2176            deadline,
2177            &[],
2178        );
2179    }
2180
2181    for (segment_start, segment_end) in segment_ranges {
2182        let segment = &original_command[segment_start..segment_end];
2183        if original_control_plane_segment_is_relevant(pack_id, segment) {
2184            let command_slice = control_plane_segment_for_matching(segment);
2185            if let Some(result) = evaluate_pack_destructive_patterns(
2186                pack_id,
2187                pack,
2188                command_slice.as_ref(),
2189                segment_start,
2190                original_command,
2191                Some(0),
2192                original_len,
2193                allowlists,
2194                project_path,
2195                first_allowlist_hit,
2196                deadline,
2197                &[],
2198            ) {
2199                return Some(result);
2200            }
2201        }
2202    }
2203
2204    None
2205}
2206
2207fn control_plane_segment_for_matching(segment: &str) -> Cow<'_, str> {
2208    if !segment.contains(['\r', '\n']) {
2209        return Cow::Borrowed(segment);
2210    }
2211
2212    let mut normalized = String::with_capacity(segment.len());
2213    for ch in segment.chars() {
2214        if matches!(ch, '\r' | '\n') {
2215            normalized.push(' ');
2216        } else {
2217            normalized.push(ch);
2218        }
2219    }
2220    Cow::Owned(normalized)
2221}
2222
2223fn command_segment_ranges(cmd: &str) -> Vec<(usize, usize)> {
2224    crate::packs::split_command_segments(cmd)
2225        .into_iter()
2226        .map(|segment| {
2227            let start = segment.as_ptr() as usize - cmd.as_ptr() as usize;
2228            (start, start + segment.len())
2229        })
2230        .collect()
2231}
2232
2233fn span_is_inside_any_segment(span: MatchSpan, segment_ranges: &[(usize, usize)]) -> bool {
2234    segment_ranges
2235        .iter()
2236        .any(|&(start, end)| span.start >= start && span.end <= end)
2237}
2238
2239fn should_check_original_control_plane_payload(
2240    pack_id: &str,
2241    command_for_packs: &str,
2242    original_command: &str,
2243) -> bool {
2244    // `curl -d/--data*` payloads are normally masked as inert data to avoid
2245    // generic false positives. Railway's API protections intentionally inspect
2246    // GraphQL mutation payloads, so re-check only that control-plane pack on an
2247    // executing curl command after the sanitized pass misses. The original
2248    // command must still carry a Railway API signal; this keeps documentation
2249    // strings such as `echo 'projectDelete RAILWAY_API_TOKEN'` masked.
2250    command_for_packs != original_command
2251        && matches!(pack_id, "platform.railway")
2252        && command_contains_curl_invocation(command_for_packs)
2253        && original_command_contains_railway_api_signal(original_command)
2254}
2255
2256fn original_control_plane_segment_is_relevant(pack_id: &str, segment: &str) -> bool {
2257    matches!(pack_id, "platform.railway")
2258        && command_contains_curl_invocation(segment)
2259        && original_command_contains_railway_api_signal(segment)
2260}
2261
2262fn command_contains_curl_invocation(command: &str) -> bool {
2263    command
2264        .split(|ch: char| ch.is_ascii_whitespace() || matches!(ch, ';' | '&' | '|' | '(' | ')'))
2265        .map(|word| word.trim_matches(['"', '\'']))
2266        .filter_map(|word| word.rsplit(['/', '\\']).next())
2267        .map(|name| {
2268            if name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(".exe") {
2269                &name[..name.len() - 4]
2270            } else {
2271                name
2272            }
2273        })
2274        .any(|name| name.eq_ignore_ascii_case("curl"))
2275}
2276
2277fn should_check_original_control_plane_payload_for_any_pack(
2278    command_for_packs: &str,
2279    original_command: &str,
2280    ordered_packs: &[String],
2281) -> bool {
2282    ordered_packs.iter().any(|pack_id| {
2283        should_check_original_control_plane_payload(pack_id, command_for_packs, original_command)
2284    })
2285}
2286
2287fn original_command_contains_railway_api_signal(command: &str) -> bool {
2288    let case_sensitive_signals = [
2289        "PROJECT_ACCESS_TOKEN",
2290        "RAILWAY_API_TOKEN",
2291        "RAILWAY_API_URL",
2292        "RAILWAY_TOKEN",
2293    ];
2294    if case_sensitive_signals
2295        .iter()
2296        .any(|signal| command.contains(signal))
2297    {
2298        return true;
2299    }
2300
2301    let lower_command = command.to_ascii_lowercase();
2302    [
2303        "backboard.railway.app",
2304        "backboard.railway.com",
2305        "project-access-token",
2306        "railway.app/graphql",
2307        "railway.com/graphql",
2308    ]
2309    .iter()
2310    .any(|signal| lower_command.contains(signal))
2311}
2312
2313#[allow(clippy::too_many_arguments)]
2314fn evaluate_pack_destructive_patterns(
2315    pack_id: &str,
2316    pack: &crate::packs::Pack,
2317    command_slice: &str,
2318    slice_offset: usize,
2319    original_command: &str,
2320    normalized_offset: Option<usize>,
2321    original_len: usize,
2322    allowlists: &LayeredAllowlist,
2323    project_path: Option<&Path>,
2324    first_allowlist_hit: &mut Option<(PatternMatch, AllowlistLayer, String)>,
2325    deadline: Option<&Deadline>,
2326    ignored_ranges: &[(usize, usize)],
2327) -> Option<EvaluationResult> {
2328    for pattern in &pack.destructive_patterns {
2329        if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH) {
2330            return Some(EvaluationResult::allowed_due_to_budget());
2331        }
2332
2333        let matched_span = pattern
2334            .regex
2335            .find(command_slice)
2336            .map(|(start, end)| MatchSpan {
2337                start: start + slice_offset,
2338                end: end + slice_offset,
2339            });
2340
2341        if deadline_exceeded(deadline) {
2342            return Some(EvaluationResult::allowed_due_to_budget());
2343        }
2344
2345        let Some(span) = matched_span else {
2346            continue;
2347        };
2348
2349        if span_is_inside_any_segment(span, ignored_ranges) {
2350            continue;
2351        }
2352
2353        let reason = pattern.reason;
2354        let mapped_span = map_span_with_offset(span, normalized_offset, original_len);
2355        let slice_span = MatchSpan {
2356            start: span.start.saturating_sub(slice_offset),
2357            end: span.end.saturating_sub(slice_offset),
2358        };
2359        let preview = mapped_span
2360            .as_ref()
2361            .map(|span| extract_match_preview(original_command, span))
2362            .or_else(|| Some(extract_match_preview(command_slice, &slice_span)));
2363
2364        if let Some(pattern_name) = pattern.name {
2365            if let Some(hit) = allowlists.match_rule_at_path(pack_id, pattern_name, project_path) {
2366                if first_allowlist_hit.is_none() {
2367                    *first_allowlist_hit = Some((
2368                        PatternMatch {
2369                            pack_id: Some(pack_id.to_string()),
2370                            pattern_name: Some(pattern_name.to_string()),
2371                            severity: Some(pattern.severity),
2372                            reason: reason.to_string(),
2373                            source: MatchSource::Pack,
2374                            matched_span: mapped_span,
2375                            matched_text_preview: preview,
2376                            explanation: pattern.explanation.map(str::to_string),
2377                            suggestions: pattern.suggestions,
2378                        },
2379                        hit.layer,
2380                        hit.entry.reason.clone(),
2381                    ));
2382                }
2383                continue;
2384            }
2385
2386            if let Some(mapped_span) = mapped_span {
2387                return Some(EvaluationResult::denied_by_pack_pattern_with_span(
2388                    pack_id,
2389                    pattern_name,
2390                    reason,
2391                    pattern.explanation,
2392                    pattern.severity,
2393                    pattern.suggestions,
2394                    original_command,
2395                    mapped_span,
2396                ));
2397            }
2398
2399            return Some(EvaluationResult::denied_by_pack_pattern(
2400                pack_id,
2401                pattern_name,
2402                reason,
2403                pattern.explanation,
2404                pattern.severity,
2405                pattern.suggestions,
2406            ));
2407        }
2408
2409        if let Some(mapped_span) = mapped_span {
2410            return Some(EvaluationResult::denied_by_pack_with_span(
2411                pack_id,
2412                reason,
2413                pattern.explanation,
2414                original_command,
2415                mapped_span,
2416            ));
2417        }
2418
2419        return Some(EvaluationResult::denied_by_pack(
2420            pack_id,
2421            reason,
2422            pattern.explanation,
2423        ));
2424    }
2425
2426    None
2427}
2428
2429/// Evaluate a command with legacy pattern support using precompiled overrides.
2430///
2431/// This version includes legacy `SAFE_PATTERNS` and `DESTRUCTIVE_PATTERNS` checking.
2432/// It's intended to be used by the main hook entrypoint until the legacy patterns
2433/// are migrated to the pack system (git_safety_guard-99e.3.4).
2434///
2435/// # Arguments
2436///
2437/// * `command` - The raw command string to evaluate
2438/// * `config` - Loaded configuration with pack settings
2439/// * `enabled_keywords` - Keywords from enabled packs for quick rejection
2440/// * `compiled_overrides` - Precompiled config overrides (avoids per-command regex compilation)
2441/// * `safe_patterns` - Legacy safe patterns (whitelist)
2442/// * `destructive_patterns` - Legacy destructive patterns (blacklist)
2443///
2444/// # Type Parameters
2445///
2446/// This function accepts any types that implement pattern matching:
2447/// * `S` - Safe pattern type with `is_match` method returning `bool`
2448/// * `D` - Destructive pattern type with `is_match` method returning `bool` and `reason` method
2449#[allow(clippy::too_many_lines)]
2450pub fn evaluate_command_with_legacy<S, D>(
2451    command: &str,
2452    config: &Config,
2453    enabled_keywords: &[&str],
2454    compiled_overrides: &crate::config::CompiledOverrides,
2455    allowlists: &LayeredAllowlist,
2456    safe_patterns: &[S],
2457    destructive_patterns: &[D],
2458) -> EvaluationResult
2459where
2460    S: LegacySafePattern,
2461    D: LegacyDestructivePattern,
2462{
2463    // Empty commands are allowed (no-op)
2464    if command.is_empty() {
2465        return EvaluationResult::allowed();
2466    }
2467
2468    // Step 1: Check allow-once overrides (may be superseded by config blocklist).
2469    let allow_once = allow_once_match(command, None);
2470
2471    // Step 2: Check precompiled block overrides before allow overrides. Deny
2472    // wins on overlapping config overrides unless allow-once was granted with
2473    // force_allow_config.
2474    if let Some(reason) = compiled_overrides.check_block(command) {
2475        if allow_once
2476            .as_ref()
2477            .is_some_and(|entry| entry.force_allow_config)
2478        {
2479            return EvaluationResult::allowed();
2480        }
2481        return EvaluationResult::denied_by_config(reason.to_string());
2482    }
2483
2484    if compiled_overrides.check_allow(command) {
2485        return EvaluationResult::allowed();
2486    }
2487
2488    if allow_once.is_some() {
2489        return EvaluationResult::allowed();
2490    }
2491
2492    // Step 2.5: Pre-calculate ordered packs for heredoc recursion (and later use)
2493    let enabled_packs: HashSet<String> = config.enabled_pack_ids();
2494    let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
2495    let keyword_index = REGISTRY.build_enabled_keyword_index(&ordered_packs);
2496
2497    // Step 3: Heredoc / inline-script detection (Tier 1/2/3, fail-open).
2498    // See `evaluate_command` for detailed rationale.
2499    let heredoc_settings = config.heredoc_settings();
2500    let mut precomputed_sanitized = None;
2501    let mut heredoc_allowlist_hit: Option<(PatternMatch, AllowlistLayer, String)> = None;
2502    let project_path = resolve_project_path(&heredoc_settings, None);
2503    let project_path = project_path.as_deref();
2504    if heredoc_settings.enabled && check_triggers(command) == TriggerResult::Triggered {
2505        let sanitized = sanitize_for_pattern_matching(command);
2506        let sanitized_str = sanitized.as_ref();
2507        let should_scan = if matches!(sanitized, std::borrow::Cow::Owned(_)) {
2508            check_triggers(sanitized_str) == TriggerResult::Triggered
2509        } else {
2510            true
2511        };
2512        precomputed_sanitized = Some(sanitized);
2513
2514        if should_scan {
2515            let context = HeredocEvaluationContext {
2516                allowlists,
2517                heredoc_settings: &heredoc_settings,
2518                project_path,
2519                deadline: None,
2520                enabled_keywords,
2521                ordered_packs: &ordered_packs,
2522                keyword_index: keyword_index.as_ref(),
2523                compiled_overrides,
2524                allow_once_audit: None,
2525            };
2526            if let Some(blocked) = evaluate_heredoc(command, context, &mut heredoc_allowlist_hit) {
2527                return blocked;
2528            }
2529        }
2530    }
2531
2532    // Step 4: Quick rejection - if no relevant keywords, allow immediately
2533    if pack_aware_quick_reject(command, enabled_keywords) {
2534        if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
2535            return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
2536        }
2537        return EvaluationResult::allowed();
2538    }
2539
2540    // Step 5: False-positive immunity - strip known-safe string arguments (commit messages, search
2541    // patterns, issue descriptions, etc.) so dangerous substrings inside data do not trigger
2542    // blocking.
2543    //
2544    // Also normalize the command here (Step 6) and reuse for pattern matching.
2545    // pack_aware_quick_reject_with_normalized returns both the quick-reject decision
2546    // and the normalized command, avoiding duplicate normalization.
2547    let sanitized = precomputed_sanitized.unwrap_or_else(|| sanitize_for_pattern_matching(command));
2548    let command_for_match = sanitized.as_ref();
2549
2550    // Use the optimized version that returns both decision and normalized form.
2551    let (quick_reject, normalized) =
2552        pack_aware_quick_reject_with_normalized(command_for_match, enabled_keywords);
2553    if quick_reject {
2554        if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
2555            return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
2556        }
2557        return EvaluationResult::allowed();
2558    }
2559
2560    // Step 7: Check legacy safe patterns (whitelist, reusing normalized from quick-reject)
2561    for pattern in safe_patterns {
2562        if pattern.is_match(&normalized) {
2563            return EvaluationResult::allowed();
2564        }
2565    }
2566
2567    let normalized_offset = compute_normalized_offset(command_for_match, &normalized);
2568    let original_len = command.len();
2569
2570    // Step 8: Check legacy destructive patterns (blacklist)
2571    for pattern in destructive_patterns {
2572        if let Some(span) = pattern.find_span(&normalized) {
2573            if let Some(mapped_span) = map_span_with_offset(span, normalized_offset, original_len) {
2574                return EvaluationResult::denied_by_legacy_with_span(
2575                    pattern.reason(),
2576                    command,
2577                    mapped_span,
2578                );
2579            }
2580            return EvaluationResult::denied_by_legacy(pattern.reason());
2581        }
2582    }
2583
2584    // Step 9: Check enabled packs with allowlist override semantics.
2585    // Note: Legacy function doesn't receive project_path - path-aware allowlisting not available here
2586    let result = evaluate_packs_with_allowlists(
2587        &normalized,
2588        &normalized,
2589        command_for_match,
2590        command,
2591        &ordered_packs,
2592        allowlists,
2593        keyword_index.as_ref(),
2594        None,
2595        None, // project_path: legacy function, path-aware allowlisting unavailable
2596    );
2597    if result.allowlist_override.is_none() {
2598        if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
2599            return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
2600        }
2601    }
2602
2603    result
2604}
2605/// Context for heredoc evaluation to avoid too many arguments.
2606#[derive(Clone, Copy)]
2607struct HeredocEvaluationContext<'a> {
2608    allowlists: &'a LayeredAllowlist,
2609    heredoc_settings: &'a crate::config::HeredocSettings,
2610    project_path: Option<&'a Path>,
2611    deadline: Option<&'a Deadline>,
2612    enabled_keywords: &'a [&'a str],
2613    ordered_packs: &'a [String],
2614    keyword_index: Option<&'a crate::packs::EnabledKeywordIndex>,
2615    compiled_overrides: &'a crate::config::CompiledOverrides,
2616    allow_once_audit: Option<&'a crate::pending_exceptions::AllowOnceAuditConfig<'a>>,
2617}
2618
2619#[allow(clippy::too_many_lines)]
2620fn evaluate_heredoc(
2621    command: &str,
2622    context: HeredocEvaluationContext<'_>,
2623    first_allowlist_hit: &mut Option<(PatternMatch, AllowlistLayer, String)>,
2624) -> Option<EvaluationResult> {
2625    if deadline_exceeded(context.deadline)
2626        || remaining_below(context.deadline, &crate::perf::FULL_HEREDOC_PIPELINE)
2627    {
2628        return Some(EvaluationResult::allowed_due_to_budget());
2629    }
2630
2631    // Check command-level allowlist before any extraction.
2632    // This allows users to whitelist entire commands (e.g., "./scripts/approved.sh").
2633    if let Some(ref content_allowlist) = context.heredoc_settings.content_allowlist {
2634        if let Some(matched_cmd) = content_allowlist.is_command_allowlisted(command) {
2635            tracing::debug!(matched_command = matched_cmd, "heredoc command allowlisted");
2636            // Command is allowlisted - skip all heredoc analysis
2637            return None;
2638        }
2639    }
2640
2641    let (contents, fallback_needed) =
2642        match extract_content(command, &context.heredoc_settings.limits) {
2643            ExtractionResult::Extracted(contents) => (contents, false),
2644            ExtractionResult::NoContent => return None,
2645            ExtractionResult::Skipped(reasons) => {
2646                let is_timeout = reasons
2647                    .iter()
2648                    .any(|r| matches!(r, SkipReason::Timeout { .. }));
2649
2650                let strict_timeout = is_timeout && !context.heredoc_settings.fallback_on_timeout;
2651                let strict_other = !is_timeout && !context.heredoc_settings.fallback_on_parse_error;
2652                if strict_timeout || strict_other {
2653                    let summary = reasons
2654                        .iter()
2655                        .map(std::string::ToString::to_string)
2656                        .collect::<Vec<_>>()
2657                        .join("; ");
2658                    let reason = if strict_timeout {
2659                        format!(
2660                            "Embedded code blocked: extraction exceeded timeout and \
2661                         fallback_on_timeout=false ({summary})"
2662                        )
2663                    } else {
2664                        format!(
2665                            "Embedded code blocked: extraction skipped and \
2666                         fallback_on_parse_error=false ({summary})"
2667                        )
2668                    };
2669                    return Some(EvaluationResult::denied_by_legacy(&reason));
2670                }
2671
2672                // Fallback check: if skipped due to size limits, perform a rudimentary
2673                // substring check for critical patterns that would otherwise be missed.
2674                if reasons
2675                    .iter()
2676                    .any(|r| matches!(r, SkipReason::ExceededSizeLimit { .. }))
2677                {
2678                    if let Some(blocked) = check_fallback_patterns(command) {
2679                        return Some(blocked);
2680                    }
2681                }
2682
2683                return None;
2684            }
2685            ExtractionResult::Partial { extracted, skipped } => {
2686                // Check strict mode settings for skipped items
2687                let is_timeout = skipped
2688                    .iter()
2689                    .any(|r| matches!(r, SkipReason::Timeout { .. }));
2690
2691                let strict_timeout = is_timeout && !context.heredoc_settings.fallback_on_timeout;
2692                let strict_other = !is_timeout && !context.heredoc_settings.fallback_on_parse_error;
2693                if strict_timeout || strict_other {
2694                    let summary = skipped
2695                        .iter()
2696                        .map(std::string::ToString::to_string)
2697                        .collect::<Vec<_>>()
2698                        .join("; ");
2699                    let reason = if strict_timeout {
2700                        format!(
2701                            "Embedded code blocked: extraction exceeded timeout (partial) and \
2702                         fallback_on_timeout=false ({summary})"
2703                        )
2704                    } else {
2705                        format!(
2706                            "Embedded code blocked: extraction partial and \
2707                         fallback_on_parse_error=false ({summary})"
2708                        )
2709                    };
2710                    return Some(EvaluationResult::denied_by_legacy(&reason));
2711                }
2712
2713                // We have partial content. Analyze what we extracted first (high fidelity).
2714                // Then if no block, run fallback checks on the whole command if size limit was exceeded.
2715                let fallback_needed = skipped
2716                    .iter()
2717                    .any(|r| matches!(r, SkipReason::ExceededSizeLimit { .. }));
2718
2719                (extracted, fallback_needed)
2720            }
2721            ExtractionResult::Failed(err) => {
2722                if !context.heredoc_settings.fallback_on_parse_error {
2723                    let reason = format!(
2724                        "Embedded code blocked: extraction failed and \
2725                     fallback_on_parse_error=false ({err})"
2726                    );
2727                    return Some(EvaluationResult::denied_by_legacy(&reason));
2728                }
2729
2730                return None;
2731            }
2732        };
2733
2734    for content in contents {
2735        if deadline_exceeded(context.deadline)
2736            || remaining_below(context.deadline, &crate::perf::FULL_HEREDOC_PIPELINE)
2737        {
2738            return Some(EvaluationResult::allowed_due_to_budget());
2739        }
2740
2741        if let Some(allowed) = &context.heredoc_settings.allowed_languages {
2742            if !allowed.contains(&content.language) {
2743                continue;
2744            }
2745        }
2746
2747        // Check content-level allowlist before AST matching.
2748        // This allows users to whitelist specific patterns or content hashes.
2749        if let Some(ref content_allowlist) = context.heredoc_settings.content_allowlist {
2750            if let Some(hit) = content_allowlist.is_content_allowlisted(
2751                &content.content,
2752                content.language,
2753                context.project_path,
2754            ) {
2755                tracing::debug!(
2756                    hit_kind = hit.kind.label(),
2757                    matched = hit.matched,
2758                    reason = hit.reason,
2759                    "heredoc content allowlisted"
2760                );
2761                // Content is allowlisted - skip AST matching for this heredoc
2762                continue;
2763            }
2764        }
2765
2766        // Skip ALL heredoc content analysis if the target command is non-executing.
2767        // Commands like `cat`, `tee`, `grep`, etc. just output the heredoc content
2768        // as data - they don't execute it as code. This prevents false positives
2769        // where documentation text containing dangerous command examples is blocked.
2770        if content
2771            .target_command
2772            .as_ref()
2773            .is_some_and(|cmd| crate::heredoc::is_non_executing_heredoc_command(cmd))
2774        {
2775            tracing::trace!(
2776                target_command = ?content.target_command,
2777                "Skipping heredoc content analysis for non-executing target"
2778            );
2779            continue; // Skip to next extracted content - this heredoc is just data
2780        }
2781
2782        // Tier 2.5: Recursive Shell Analysis
2783        // If content is Bash, extract inner commands and feed them back to the full evaluator.
2784        // This ensures that `kubectl`, `docker`, etc. inside heredocs are checked against their packs.
2785        if content.language == crate::heredoc::ScriptLanguage::Bash {
2786            // Fast pre-filter: skip the expensive tree-sitter AST parse if the
2787            // heredoc body contains none of the enabled pack keywords. The AC
2788            // automaton does a single O(n) scan; the AST parse is much heavier.
2789            let body_has_keywords = context.keyword_index.map_or_else(
2790                || {
2791                    context.enabled_keywords.iter().any(|kw| {
2792                        memchr::memmem::find(content.content.as_bytes(), kw.as_bytes()).is_some()
2793                    })
2794                },
2795                |index| index.has_any_keyword(&content.content),
2796            );
2797
2798            if body_has_keywords {
2799                let inner_commands = crate::heredoc::extract_shell_commands(&content.content);
2800                for inner in inner_commands {
2801                    if deadline_exceeded(context.deadline) {
2802                        return Some(EvaluationResult::allowed_due_to_budget());
2803                    }
2804
2805                    let result = evaluate_command_with_pack_order_deadline_at_path(
2806                        &inner.text,
2807                        context.enabled_keywords,
2808                        context.ordered_packs,
2809                        context.keyword_index,
2810                        context.compiled_overrides,
2811                        context.allowlists,
2812                        context.heredoc_settings,
2813                        context.allow_once_audit,
2814                        context.project_path,
2815                        context.deadline,
2816                    );
2817
2818                    if result.is_denied() {
2819                        // Propagate denial, wrapping the reason context
2820                        if let Some(mut info) = result.pattern_info {
2821                            info.reason = format!(
2822                                "Embedded shell command blocked: {} (line {} of heredoc)",
2823                                info.reason, inner.line_number
2824                            );
2825                            info.source = MatchSource::HeredocAst; // Mark as heredoc source
2826                            if let Some(span) = info.matched_span {
2827                                if let Some(mapped_inner) =
2828                                    map_heredoc_span(command, &content, inner.start, inner.end)
2829                                {
2830                                    let mapped = MatchSpan {
2831                                        start: mapped_inner.start.saturating_add(span.start),
2832                                        end: mapped_inner.start.saturating_add(span.end),
2833                                    };
2834                                    if mapped.end <= command.len() {
2835                                        info.matched_span = Some(mapped);
2836                                        info.matched_text_preview =
2837                                            Some(extract_match_preview(command, &mapped));
2838                                    } else {
2839                                        info.matched_span = None;
2840                                    }
2841                                } else {
2842                                    info.matched_span = None;
2843                                }
2844                            }
2845
2846                            return Some(EvaluationResult {
2847                                decision: EvaluationDecision::Deny,
2848                                pattern_info: Some(info),
2849                                allowlist_override: None,
2850                                effective_mode: Some(crate::packs::DecisionMode::Deny),
2851                                skipped_due_to_budget: false,
2852                                branch_context: None,
2853                                session_occurrence: None,
2854                                graduated_response: None,
2855                                bypass_method: None,
2856                            });
2857                        }
2858                        return Some(result);
2859                    }
2860                }
2861            } // body_has_keywords
2862        }
2863
2864        let matches = match DEFAULT_MATCHER.find_matches(&content.content, content.language) {
2865            Ok(matches) => matches,
2866            Err(err) => {
2867                let is_timeout = matches!(err, crate::ast_matcher::MatchError::Timeout { .. });
2868                let strict_timeout = is_timeout && !context.heredoc_settings.fallback_on_timeout;
2869                let strict_other = !is_timeout && !context.heredoc_settings.fallback_on_parse_error;
2870                if strict_timeout || strict_other {
2871                    let reason = format!(
2872                        "Embedded code blocked: AST matching error with strict fallback \
2873                         configuration ({err})"
2874                    );
2875                    return Some(EvaluationResult::denied_by_legacy(&reason));
2876                }
2877
2878                continue;
2879            }
2880        };
2881
2882        for m in matches {
2883            if deadline_exceeded(context.deadline)
2884                || remaining_below(context.deadline, &crate::perf::FULL_HEREDOC_PIPELINE)
2885            {
2886                return Some(EvaluationResult::allowed_due_to_budget());
2887            }
2888
2889            if !m.severity.blocks_by_default() {
2890                continue;
2891            }
2892
2893            let (pack_id, pattern_name) = split_ast_rule_id(&m.rule_id);
2894
2895            if let Some(hit) = context.allowlists.match_rule(&pack_id, &pattern_name) {
2896                if first_allowlist_hit.is_none() {
2897                    let reason =
2898                        format_heredoc_denial_reason(&content, &m, &pack_id, &pattern_name);
2899                    let mapped_span = map_heredoc_span(command, &content, m.start, m.end);
2900                    *first_allowlist_hit = Some((
2901                        PatternMatch {
2902                            pack_id: Some(pack_id),
2903                            pattern_name: Some(pattern_name),
2904                            severity: Some(ast_severity_to_pack_severity(m.severity)),
2905                            reason,
2906                            source: MatchSource::HeredocAst,
2907                            matched_span: mapped_span,
2908                            matched_text_preview: Some(m.matched_text_preview),
2909                            explanation: None,
2910                            suggestions: &[],
2911                        },
2912                        hit.layer,
2913                        hit.entry.reason.clone(),
2914                    ));
2915                }
2916                continue;
2917            }
2918
2919            let reason = format_heredoc_denial_reason(&content, &m, &pack_id, &pattern_name);
2920            let mapped_span = map_heredoc_span(command, &content, m.start, m.end);
2921            return Some(EvaluationResult {
2922                decision: EvaluationDecision::Deny,
2923                pattern_info: Some(PatternMatch {
2924                    pack_id: Some(pack_id),
2925                    pattern_name: Some(pattern_name),
2926                    severity: Some(ast_severity_to_pack_severity(m.severity)),
2927                    reason,
2928                    source: MatchSource::HeredocAst,
2929                    matched_span: mapped_span,
2930                    matched_text_preview: Some(m.matched_text_preview),
2931                    explanation: None,
2932                    suggestions: &[],
2933                }),
2934                allowlist_override: None,
2935                effective_mode: Some(crate::packs::DecisionMode::Deny),
2936                skipped_due_to_budget: false,
2937                branch_context: None,
2938                session_occurrence: None,
2939                graduated_response: None,
2940                bypass_method: None,
2941            });
2942        }
2943    }
2944
2945    if fallback_needed {
2946        if let Some(blocked) = check_fallback_patterns(command) {
2947            return Some(blocked);
2948        }
2949    }
2950
2951    None
2952}
2953
2954#[allow(dead_code)]
2955fn check_fallback_patterns(command: &str) -> Option<EvaluationResult> {
2956    // List of critical destructive patterns to check when AST analysis is skipped (e.g. oversized input).
2957    // These patterns must be robust to whitespace variations where applicable.
2958    static FALLBACK_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
2959        RegexSet::new([
2960            r"shutil\.rmtree",
2961            r"os\.remove",
2962            r"os\.rmdir",
2963            r"os\.unlink",
2964            r"fs\.rmSync",
2965            r"fs\.rmdirSync",
2966            r"child_process\.execSync",
2967            r"child_process\.spawnSync",
2968            r"os\.RemoveAll",
2969            r"\brm\s+(?:-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)\b", // rm -rf, rm -fr, rm -r -f
2970            r"\bgit\s+reset\s+--hard\b",
2971        ])
2972        .expect("fallback patterns must compile")
2973    });
2974
2975    // Sanitize the command first to mask comments and safe arguments (e.g. commit messages).
2976    // This prevents false positives where a destructive command is mentioned in a comment
2977    // inside a large heredoc.
2978    let sanitized = sanitize_for_pattern_matching(command);
2979    let check_target = sanitized.as_ref();
2980
2981    if FALLBACK_PATTERNS.is_match(check_target) {
2982        return Some(EvaluationResult::denied_by_legacy(
2983            "Oversized command contains destructive pattern (fallback check)",
2984        ));
2985    }
2986
2987    None
2988}
2989
2990fn split_ast_rule_id(rule_id: &str) -> (String, String) {
2991    // Expected format: heredoc.<language>.<pattern>[.<suffix>...]
2992    if let Some(rest) = rule_id.strip_prefix("heredoc.") {
2993        if let Some((lang, tail)) = rest.split_once('.') {
2994            let pack_id = format!("heredoc.{lang}");
2995            return (pack_id, tail.to_string());
2996        }
2997        return ("heredoc".to_string(), rule_id.to_string());
2998    }
2999
3000    // Fallback: best-effort split on last dot.
3001    if let Some((pack_id, pattern_name)) = rule_id.rsplit_once('.') {
3002        return (pack_id.to_string(), pattern_name.to_string());
3003    }
3004
3005    ("unknown".to_string(), rule_id.to_string())
3006}
3007
3008fn format_heredoc_denial_reason(
3009    extracted: &crate::heredoc::ExtractedContent,
3010    m: &crate::ast_matcher::PatternMatch,
3011    pack_id: &str,
3012    pattern_name: &str,
3013) -> String {
3014    let lang = match extracted.language {
3015        crate::heredoc::ScriptLanguage::Bash => "bash",
3016        crate::heredoc::ScriptLanguage::Go => "go",
3017        crate::heredoc::ScriptLanguage::Python => "python",
3018        crate::heredoc::ScriptLanguage::Ruby => "ruby",
3019        crate::heredoc::ScriptLanguage::Perl => "perl",
3020        crate::heredoc::ScriptLanguage::JavaScript => "javascript",
3021        crate::heredoc::ScriptLanguage::TypeScript => "typescript",
3022        crate::heredoc::ScriptLanguage::Php => "php",
3023        crate::heredoc::ScriptLanguage::Unknown => "unknown",
3024    };
3025
3026    format!(
3027        "Embedded {lang} code blocked: {} (rule {pack_id}:{pattern_name}, line {}, matched: {})",
3028        m.reason, m.line_number, m.matched_text_preview
3029    )
3030}
3031
3032fn map_heredoc_span(
3033    command: &str,
3034    content: &crate::heredoc::ExtractedContent,
3035    start: usize,
3036    end: usize,
3037) -> Option<MatchSpan> {
3038    let range = content.content_range.as_ref()?;
3039    let raw = command.get(range.clone())?;
3040    if raw.len() != content.content.len() {
3041        return None;
3042    }
3043    if raw != content.content {
3044        return None;
3045    }
3046
3047    let mapped_start = range.start.saturating_add(start);
3048    let mapped_end = range.start.saturating_add(end);
3049    if mapped_start <= mapped_end && mapped_end <= command.len() {
3050        Some(MatchSpan {
3051            start: mapped_start,
3052            end: mapped_end,
3053        })
3054    } else {
3055        None
3056    }
3057}
3058
3059/// Trait for legacy safe patterns.
3060pub trait LegacySafePattern {
3061    /// Check if the pattern matches the command.
3062    fn is_match(&self, cmd: &str) -> bool;
3063}
3064
3065/// Trait for legacy destructive patterns.
3066pub trait LegacyDestructivePattern {
3067    /// Check if the pattern matches the command.
3068    fn is_match(&self, cmd: &str) -> bool;
3069    /// Find the first match span, if available.
3070    fn find_span(&self, cmd: &str) -> Option<MatchSpan> {
3071        let _ = cmd;
3072        None
3073    }
3074    /// Get the reason for blocking.
3075    fn reason(&self) -> &str;
3076}
3077
3078impl LegacySafePattern for crate::packs::SafePattern {
3079    fn is_match(&self, cmd: &str) -> bool {
3080        self.regex.is_match(cmd)
3081    }
3082}
3083
3084impl LegacyDestructivePattern for crate::packs::DestructivePattern {
3085    fn is_match(&self, cmd: &str) -> bool {
3086        self.regex.is_match(cmd)
3087    }
3088
3089    fn find_span(&self, cmd: &str) -> Option<MatchSpan> {
3090        self.regex
3091            .find(cmd)
3092            .map(|(start, end)| MatchSpan { start, end })
3093    }
3094
3095    fn reason(&self) -> &str {
3096        self.reason
3097    }
3098}
3099
3100// =============================================================================
3101// Confidence Scoring Integration (git_safety_guard-t8x.5)
3102// =============================================================================
3103
3104/// Result of applying confidence scoring to a decision.
3105#[derive(Debug, Clone)]
3106pub struct ConfidenceResult {
3107    /// The (potentially adjusted) decision mode.
3108    pub mode: crate::packs::DecisionMode,
3109    /// The confidence score (if computed).
3110    pub score: Option<crate::confidence::ConfidenceScore>,
3111    /// Whether the mode was downgraded due to low confidence.
3112    pub downgraded: bool,
3113}
3114
3115/// Apply confidence scoring to potentially downgrade a Deny to Warn.
3116///
3117/// This function computes a confidence score for the pattern match and
3118/// optionally downgrades the decision mode if confidence is low.
3119///
3120/// # Arguments
3121///
3122/// * `command` - The original command being evaluated
3123/// * `sanitized_command` - The sanitized version (with safe data masked), if available
3124/// * `result` - The evaluation result (must have `pattern_info` for confidence to apply)
3125/// * `current_mode` - The decision mode from policy resolution
3126/// * `config` - Confidence scoring configuration
3127///
3128/// # Returns
3129///
3130/// A `ConfidenceResult` with the (potentially adjusted) mode and confidence details.
3131#[must_use]
3132pub fn apply_confidence_scoring(
3133    command: &str,
3134    sanitized_command: Option<&str>,
3135    result: &EvaluationResult,
3136    current_mode: crate::packs::DecisionMode,
3137    config: &crate::config::ConfidenceConfig,
3138) -> ConfidenceResult {
3139    // If confidence scoring is disabled, return unchanged mode
3140    if !config.enabled {
3141        return ConfidenceResult {
3142            mode: current_mode,
3143            score: None,
3144            downgraded: false,
3145        };
3146    }
3147
3148    // Only apply confidence scoring to Deny decisions that might be downgraded
3149    if current_mode != crate::packs::DecisionMode::Deny {
3150        return ConfidenceResult {
3151            mode: current_mode,
3152            score: None,
3153            downgraded: false,
3154        };
3155    }
3156
3157    // Need pattern info to compute confidence
3158    let Some(info) = &result.pattern_info else {
3159        return ConfidenceResult {
3160            mode: current_mode,
3161            score: None,
3162            downgraded: false,
3163        };
3164    };
3165
3166    // Protect Critical severity from downgrading (if configured)
3167    if config.protect_critical
3168        && info
3169            .severity
3170            .is_some_and(|s| s == crate::packs::Severity::Critical)
3171    {
3172        return ConfidenceResult {
3173            mode: current_mode,
3174            score: None,
3175            downgraded: false,
3176        };
3177    }
3178
3179    // Get match span for confidence computation
3180    let Some(span) = &info.matched_span else {
3181        // No span = can't compute confidence = conservative (keep Deny)
3182        return ConfidenceResult {
3183            mode: current_mode,
3184            score: None,
3185            downgraded: false,
3186        };
3187    };
3188
3189    // Compute confidence
3190    let ctx = crate::confidence::ConfidenceContext {
3191        command,
3192        sanitized_command,
3193        match_start: span.start,
3194        match_end: span.end,
3195    };
3196    let score = crate::confidence::compute_match_confidence(&ctx);
3197
3198    // Check if we should downgrade
3199    let should_downgrade = score.is_low(config.warn_threshold);
3200    let new_mode = if should_downgrade {
3201        crate::packs::DecisionMode::Warn
3202    } else {
3203        current_mode
3204    };
3205
3206    ConfidenceResult {
3207        mode: new_mode,
3208        score: Some(score),
3209        downgraded: should_downgrade,
3210    }
3211}
3212
3213/// Apply git branch-aware strictness to an evaluation result.
3214///
3215/// This function modifies the evaluation result based on the current git branch:
3216/// - On protected branches (e.g., main, master), stricter settings are applied
3217/// - On relaxed branches (e.g., feature/*), more permissive settings are applied
3218/// - The branch_context field is populated with branch information
3219///
3220/// # Arguments
3221/// * `result` - The original evaluation result
3222/// * `config` - Configuration containing git_awareness settings
3223/// * `project_path` - Optional path to the project directory (for branch detection)
3224///
3225/// # Returns
3226/// A modified evaluation result with branch context applied.
3227#[must_use]
3228pub fn apply_branch_strictness(
3229    mut result: EvaluationResult,
3230    config: &Config,
3231    project_path: Option<&Path>,
3232) -> EvaluationResult {
3233    // Early return if git awareness is disabled
3234    let git_awareness = &config.git_awareness;
3235    if !git_awareness.enabled {
3236        return result;
3237    }
3238
3239    // Get branch info
3240    let branch_info = match project_path {
3241        Some(path) => crate::git::get_branch_info_at_path(path),
3242        None => crate::git::get_branch_info(),
3243    };
3244
3245    // Extract branch name if available
3246    let is_detached_head = matches!(&branch_info, crate::git::BranchInfo::DetachedHead(_));
3247    let branch_name = match &branch_info {
3248        crate::git::BranchInfo::Branch(name) => Some(name.clone()),
3249        crate::git::BranchInfo::DetachedHead(_) => None,
3250        crate::git::BranchInfo::NotGitRepo => {
3251            // Not in a git repo - graceful degradation with default strictness
3252            tracing::debug!(
3253                "Not in git repository, using default strictness (git_awareness enabled but no repo detected)"
3254            );
3255            // Optionally warn if configured
3256            if config.git_awareness.warn_if_not_git {
3257                tracing::warn!(
3258                    "dcg git_awareness is enabled but not in a git repository - using default strictness"
3259                );
3260            }
3261            return result;
3262        }
3263    };
3264
3265    // Determine branch characteristics
3266    let is_protected = branch_name
3267        .as_ref()
3268        .is_some_and(|name| git_awareness.is_protected_branch(Some(name.as_str())));
3269    let is_relaxed = branch_name
3270        .as_ref()
3271        .is_some_and(|name| git_awareness.is_relaxed_branch(Some(name.as_str())));
3272    // Detached HEAD (rebase / bisect / checkout-tag) gets its own strictness
3273    // knob — defaults to All. Without this branch, detached HEAD silently fell
3274    // back to default_strictness (typically High), missing the very contexts
3275    // where uncommitted work is most exposed.
3276    let strictness = if is_detached_head {
3277        git_awareness.detached_head_strictness
3278    } else {
3279        git_awareness.strictness_for_branch(branch_name.as_deref())
3280    };
3281
3282    // Determine if the decision should be affected
3283    let mut affected_decision = false;
3284
3285    // If the result is Deny and we have severity info, check strictness
3286    if result.decision == EvaluationDecision::Deny {
3287        if let Some(ref pattern_info) = result.pattern_info {
3288            if let Some(severity) = pattern_info.severity {
3289                // Check if this severity should be blocked at the current strictness
3290                if !strictness.should_block(severity) {
3291                    // Convert Deny to Allow because strictness permits it
3292                    result.decision = EvaluationDecision::Allow;
3293                    affected_decision = true;
3294                }
3295            }
3296        }
3297    }
3298
3299    // Populate branch context
3300    result.branch_context = Some(BranchContext {
3301        branch_name,
3302        is_protected,
3303        is_relaxed,
3304        strictness,
3305        affected_decision,
3306    });
3307
3308    result
3309}
3310
3311#[cfg(test)]
3312mod tests {
3313    use super::*;
3314    use crate::allowlist::{
3315        AllowEntry, AllowSelector, AllowlistFile, LoadedAllowlistLayer, RuleId,
3316    };
3317    use std::collections::HashMap;
3318    use std::path::PathBuf;
3319    use std::sync::atomic::{AtomicUsize, Ordering};
3320
3321    static COUNTER: AtomicUsize = AtomicUsize::new(0);
3322
3323    fn default_config() -> Config {
3324        Config::default()
3325    }
3326
3327    fn default_compiled_overrides() -> crate::config::CompiledOverrides {
3328        crate::config::CompiledOverrides::default()
3329    }
3330
3331    fn default_allowlists() -> LayeredAllowlist {
3332        LayeredAllowlist::default()
3333    }
3334
3335    fn evaluate_with_pack_ids(command: &str, pack_ids: &[&str]) -> EvaluationResult {
3336        let enabled_packs: std::collections::HashSet<String> =
3337            pack_ids.iter().map(|id| (*id).to_string()).collect();
3338        let ordered_packs = crate::packs::REGISTRY.expand_enabled_ordered(&enabled_packs);
3339        let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
3340        let enabled_keywords = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
3341        let compiled = default_compiled_overrides();
3342        let allowlists = default_allowlists();
3343        let heredoc_settings = default_config().heredoc_settings();
3344
3345        evaluate_command_with_pack_order(
3346            command,
3347            enabled_keywords.as_slice(),
3348            ordered_packs.as_slice(),
3349            keyword_index.as_ref(),
3350            &compiled,
3351            &allowlists,
3352            &heredoc_settings,
3353        )
3354    }
3355
3356    fn project_allowlists_for_rule(rule: &str, reason: &str) -> LayeredAllowlist {
3357        let rule = RuleId::parse(rule).expect("rule id must parse");
3358        LayeredAllowlist {
3359            layers: vec![LoadedAllowlistLayer {
3360                layer: AllowlistLayer::Project,
3361                path: PathBuf::from("project-allowlist.toml"),
3362                file: AllowlistFile {
3363                    entries: vec![AllowEntry {
3364                        selector: AllowSelector::Rule(rule),
3365                        reason: reason.to_string(),
3366                        added_by: None,
3367                        added_at: None,
3368                        expires_at: None,
3369                        ttl: None,
3370                        session: None,
3371                        session_id: None,
3372                        context: None,
3373                        conditions: HashMap::new(),
3374                        environments: Vec::new(),
3375                        paths: None,
3376                        risk_acknowledged: false,
3377                    }],
3378                    errors: Vec::new(),
3379                },
3380            }],
3381        }
3382    }
3383
3384    #[allow(dead_code)]
3385    fn project_allowlists_for_pack_wildcard(pack_id: &str, reason: &str) -> LayeredAllowlist {
3386        LayeredAllowlist {
3387            layers: vec![LoadedAllowlistLayer {
3388                layer: AllowlistLayer::Project,
3389                path: PathBuf::from("project-allowlist.toml"),
3390                file: AllowlistFile {
3391                    entries: vec![AllowEntry {
3392                        selector: AllowSelector::Rule(RuleId {
3393                            pack_id: pack_id.to_string(),
3394                            pattern_name: "*".to_string(),
3395                        }),
3396                        reason: reason.to_string(),
3397                        added_by: None,
3398                        added_at: None,
3399                        expires_at: None,
3400                        ttl: None,
3401                        session: None,
3402                        session_id: None,
3403                        context: None,
3404                        conditions: HashMap::new(),
3405                        environments: Vec::new(),
3406                        paths: None,
3407                        risk_acknowledged: false,
3408                    }],
3409                    errors: Vec::new(),
3410                },
3411            }],
3412        }
3413    }
3414
3415    #[test]
3416    fn test_empty_command_allowed() {
3417        let config = default_config();
3418        let compiled = default_compiled_overrides();
3419        let allowlists = default_allowlists();
3420        let result = evaluate_command("", &config, &[], &compiled, &allowlists);
3421        assert!(result.is_allowed());
3422        assert!(result.pattern_info.is_none());
3423    }
3424
3425    #[test]
3426    fn test_safe_command_allowed() {
3427        let config = default_config();
3428        let compiled = default_compiled_overrides();
3429        let allowlists = default_allowlists();
3430        let result = evaluate_command("ls -la", &config, &["git", "rm"], &compiled, &allowlists);
3431        assert!(result.is_allowed());
3432    }
3433
3434    #[test]
3435    fn non_core_safe_segment_does_not_mask_later_destructive_segment() {
3436        let result = evaluate_with_pack_ids(
3437            "railway service list && railway volume delete --volume prod-db --yes",
3438            &["platform.railway"],
3439        );
3440
3441        assert!(result.is_denied(), "Railway volume delete must be blocked");
3442        let info = result
3443            .pattern_info
3444            .expect("denial should include pattern info");
3445        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3446        assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
3447    }
3448
3449    #[test]
3450    fn non_core_safe_pipeline_stage_does_not_mask_later_destructive_stage() {
3451        let result = evaluate_with_pack_ids(
3452            "railway service list | railway volume delete --volume prod-db --yes",
3453            &["platform.railway"],
3454        );
3455
3456        assert!(
3457            result.is_denied(),
3458            "Railway volume delete must be blocked after a safe pipeline stage"
3459        );
3460        let info = result
3461            .pattern_info
3462            .expect("denial should include pattern info");
3463        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3464        assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
3465    }
3466
3467    #[test]
3468    fn non_core_safe_background_command_does_not_mask_later_destructive_command() {
3469        let result = evaluate_with_pack_ids(
3470            "railway service list & railway volume delete --volume prod-db --yes",
3471            &["platform.railway"],
3472        );
3473
3474        assert!(
3475            result.is_denied(),
3476            "Railway volume delete must be blocked after a safe background command"
3477        );
3478        let info = result
3479            .pattern_info
3480            .expect("denial should include pattern info");
3481        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3482        assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
3483    }
3484
3485    #[test]
3486    fn non_core_safe_segment_does_not_mask_earlier_destructive_segment() {
3487        let result = evaluate_with_pack_ids(
3488            "railway volume delete --volume prod-db --yes && railway service list",
3489            &["platform.railway"],
3490        );
3491
3492        assert!(
3493            result.is_denied(),
3494            "Railway volume delete must be blocked before a safe segment"
3495        );
3496        let info = result
3497            .pattern_info
3498            .expect("denial should include pattern info");
3499        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3500        assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
3501    }
3502
3503    #[test]
3504    fn non_core_safe_segments_remain_allowed() {
3505        let result = evaluate_with_pack_ids(
3506            "railway service list && railway volume list --json",
3507            &["platform.railway"],
3508        );
3509
3510        assert!(
3511            result.is_allowed(),
3512            "read-only Railway segments should pass"
3513        );
3514    }
3515
3516    #[test]
3517    fn railway_api_mutations_in_curl_payloads_are_not_hidden_by_data_masking() {
3518        let result = evaluate_with_pack_ids(
3519            r#"curl https://backboard.railway.app/graphql/v2 --data-binary '{"query":"mutation($in: VariableUpsertInput!){variableUpsert(input:$in)}","variables":{"in":{"name":"DATABASE_PUBLIC_URL","value":"postgres://prod"}}}'"#,
3520            &["platform.railway"],
3521        );
3522
3523        assert!(
3524            result.is_denied(),
3525            "Railway API variableUpsert payload must be blocked"
3526        );
3527        let info = result
3528            .pattern_info
3529            .expect("denial should include pattern info");
3530        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3531        assert_eq!(
3532            info.pattern_name.as_deref(),
3533            Some("railway-api-database-variable-upsert")
3534        );
3535    }
3536
3537    #[test]
3538    fn railway_api_payload_recheck_detects_windows_curl_exe() {
3539        for curl_binary in [
3540            r"C:\Windows\System32\curl.exe",
3541            r"C:\Windows\System32\CURL.EXE",
3542        ] {
3543            let result = evaluate_with_pack_ids(
3544                &format!(
3545                    r#"{curl_binary} https://backboard.railway.app/graphql/v2 --data-binary '{{"query":"mutation {{ projectDelete(id:\"p\") }}"}}'"#
3546                ),
3547                &["platform.railway"],
3548            );
3549
3550            assert!(
3551                result.is_denied(),
3552                "Railway API mutation through {curl_binary} must still be blocked"
3553            );
3554            let info = result
3555                .pattern_info
3556                .expect("denial should include pattern info");
3557            assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3558            assert_eq!(
3559                info.pattern_name.as_deref(),
3560                Some("railway-api-project-delete")
3561            );
3562        }
3563    }
3564
3565    #[test]
3566    fn railway_api_mutations_with_token_header_are_not_hidden_by_data_masking() {
3567        let result = evaluate_with_pack_ids(
3568            r#"curl https://api.example.com/graphql -H "Authorization: Bearer $RAILWAY_API_TOKEN" --data-binary '{"query":"mutation { projectDelete(id:\"p\") }"}'"#,
3569            &["platform.railway"],
3570        );
3571
3572        assert!(
3573            result.is_denied(),
3574            "Railway API mutation authenticated by token header must be blocked"
3575        );
3576        let info = result
3577            .pattern_info
3578            .expect("denial should include pattern info");
3579        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3580        assert_eq!(
3581            info.pattern_name.as_deref(),
3582            Some("railway-api-project-delete")
3583        );
3584    }
3585
3586    #[test]
3587    fn railway_api_mutations_with_project_access_token_are_not_hidden_by_data_masking() {
3588        let result = evaluate_with_pack_ids(
3589            r#"curl https://api.example.com/graphql -H "Project-Access-Token: $PROJECT_ACCESS_TOKEN" --data-binary '{"query":"mutation { projectDelete(id:\"p\") }"}'"#,
3590            &["platform.railway"],
3591        );
3592
3593        assert!(
3594            result.is_denied(),
3595            "Railway API mutation authenticated by Project-Access-Token must be blocked"
3596        );
3597        let info = result
3598            .pattern_info
3599            .expect("denial should include pattern info");
3600        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3601        assert_eq!(
3602            info.pattern_name.as_deref(),
3603            Some("railway-api-project-delete")
3604        );
3605    }
3606
3607    #[test]
3608    fn railway_api_payload_recheck_does_not_cross_compound_segments() {
3609        let result = evaluate_with_pack_ids(
3610            r#"curl https://backboard.railway.app/graphql/v2 --data-binary '{"query":"query { project(id:\"p\") { id } }"}' && echo projectDelete"#,
3611            &["platform.railway"],
3612        );
3613
3614        assert!(
3615            result.is_allowed(),
3616            "safe Railway API query plus unrelated documentation text should stay allowed"
3617        );
3618    }
3619
3620    #[test]
3621    fn railway_api_payload_recheck_does_not_cross_newline_segments() {
3622        let result = evaluate_with_pack_ids(
3623            "curl https://backboard.railway.app/graphql/v2 --data-binary '{\"query\":\"query { project(id:\\\"p\\\") { id } }\"}'\necho projectDelete",
3624            &["platform.railway"],
3625        );
3626
3627        assert!(
3628            result.is_allowed(),
3629            "safe Railway API query plus newline-separated documentation text should stay allowed"
3630        );
3631    }
3632
3633    #[test]
3634    fn railway_api_payload_recheck_still_blocks_destructive_curl_segment() {
3635        let result = evaluate_with_pack_ids(
3636            r#"curl https://backboard.railway.app/graphql/v2 --data-binary '{"query":"query { project(id:\"p\") { id } }"}' && curl https://backboard.railway.app/graphql/v2 --data-binary '{"query":"mutation { projectDelete(id:\"p\") }"}'"#,
3637            &["platform.railway"],
3638        );
3639
3640        assert!(
3641            result.is_denied(),
3642            "destructive Railway API mutation in a later curl segment must still be blocked"
3643        );
3644        let info = result
3645            .pattern_info
3646            .expect("denial should include pattern info");
3647        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3648        assert_eq!(
3649            info.pattern_name.as_deref(),
3650            Some("railway-api-project-delete")
3651        );
3652    }
3653
3654    #[test]
3655    fn railway_api_payload_recheck_handles_shell_line_continuations() {
3656        let result = evaluate_with_pack_ids(
3657            "curl https://backboard.railway.app/graphql/v2 \\\n  --data-binary '{\"query\":\"mutation { projectDelete(id:\\\"p\\\") }\"}'",
3658            &["platform.railway"],
3659        );
3660
3661        assert!(
3662            result.is_denied(),
3663            "Railway API mutation split with shell line continuation must still be blocked"
3664        );
3665        let info = result
3666            .pattern_info
3667            .expect("denial should include pattern info");
3668        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3669        assert_eq!(
3670            info.pattern_name.as_deref(),
3671            Some("railway-api-project-delete")
3672        );
3673    }
3674
3675    #[test]
3676    fn railway_api_payload_recheck_handles_multiline_quoted_payloads() {
3677        let result = evaluate_with_pack_ids(
3678            "curl https://backboard.railway.app/graphql/v2 --data-binary '{\n\"query\":\"mutation { projectDelete(id:\\\"p\\\") }\"\n}'",
3679            &["platform.railway"],
3680        );
3681
3682        assert!(
3683            result.is_denied(),
3684            "Railway API mutation inside a multiline quoted payload must still be blocked"
3685        );
3686        let info = result
3687            .pattern_info
3688            .expect("denial should include pattern info");
3689        assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
3690        assert_eq!(
3691            info.pattern_name.as_deref(),
3692            Some("railway-api-project-delete")
3693        );
3694    }
3695
3696    #[test]
3697    fn masked_non_curl_documentation_stays_allowed_for_railway_api_terms() {
3698        let result = evaluate_with_pack_ids(
3699            r"echo 'projectDelete with RAILWAY_API_TOKEN belongs in docs'",
3700            &["platform.railway"],
3701        );
3702
3703        assert!(
3704            result.is_allowed(),
3705            "masked documentation text should not activate Railway API inspection"
3706        );
3707    }
3708
3709    #[test]
3710    fn masked_non_curl_project_token_documentation_stays_allowed() {
3711        let result = evaluate_with_pack_ids(
3712            r"echo 'projectDelete with Project-Access-Token belongs in docs'",
3713            &["platform.railway"],
3714        );
3715
3716        assert!(
3717            result.is_allowed(),
3718            "masked project-token documentation should not activate Railway API inspection"
3719        );
3720    }
3721
3722    #[test]
3723    fn masked_non_curl_command_name_stays_allowed_for_railway_api_terms() {
3724        let result = evaluate_with_pack_ids(
3725            r#"curlgrep -H "Authorization: Bearer $RAILWAY_API_TOKEN" --data-binary '{"query":"mutation { projectDelete(id:\"p\") }"}'"#,
3726            &["platform.railway"],
3727        );
3728
3729        assert!(
3730            result.is_allowed(),
3731            "non-curl command names should not activate Railway API inspection"
3732        );
3733    }
3734
3735    #[test]
3736    fn test_result_helper_methods() {
3737        let allowed = EvaluationResult::allowed();
3738        assert!(allowed.is_allowed());
3739        assert!(!allowed.is_denied());
3740        assert!(allowed.reason().is_none());
3741        assert!(allowed.pack_id().is_none());
3742
3743        let denied = EvaluationResult::denied_by_pack("test.pack", "test reason", None);
3744        assert!(!denied.is_allowed());
3745        assert!(denied.is_denied());
3746        assert_eq!(denied.reason(), Some("test reason"));
3747        assert_eq!(denied.pack_id(), Some("test.pack"));
3748    }
3749
3750    #[test]
3751    fn test_denied_by_config() {
3752        let denied = EvaluationResult::denied_by_config("config block".to_string());
3753        assert!(denied.is_denied());
3754        assert_eq!(denied.reason(), Some("config block"));
3755        assert!(denied.pack_id().is_none());
3756        assert_eq!(
3757            denied.pattern_info.as_ref().unwrap().source,
3758            MatchSource::ConfigOverride
3759        );
3760    }
3761
3762    #[test]
3763    fn test_denied_by_legacy() {
3764        let denied = EvaluationResult::denied_by_legacy("legacy reason");
3765        assert!(denied.is_denied());
3766        assert_eq!(denied.reason(), Some("legacy reason"));
3767        assert!(denied.pack_id().is_none());
3768        assert_eq!(
3769            denied.pattern_info.as_ref().unwrap().source,
3770            MatchSource::LegacyPattern
3771        );
3772    }
3773
3774    #[test]
3775    fn test_denied_by_pack_pattern() {
3776        let denied = EvaluationResult::denied_by_pack_pattern(
3777            "core.git",
3778            "reset-hard",
3779            "test",
3780            None,
3781            crate::packs::Severity::Critical,
3782            &[],
3783        );
3784        assert!(denied.is_denied());
3785        assert_eq!(denied.pack_id(), Some("core.git"));
3786        assert_eq!(
3787            denied.pattern_info.as_ref().unwrap().pattern_name,
3788            Some("reset-hard".to_string())
3789        );
3790    }
3791
3792    #[test]
3793    fn test_quick_reject_skips_patterns() {
3794        let config = default_config();
3795        let compiled = default_compiled_overrides();
3796        let allowlists = default_allowlists();
3797        let result = evaluate_command(
3798            "cargo build --release",
3799            &config,
3800            &["git", "rm"],
3801            &compiled,
3802            &allowlists,
3803        );
3804        assert!(result.is_allowed());
3805
3806        // Even with more keywords
3807        let result = evaluate_command(
3808            "npm install",
3809            &config,
3810            &["git", "rm", "docker", "kubectl"],
3811            &compiled,
3812            &allowlists,
3813        );
3814        assert!(result.is_allowed());
3815    }
3816
3817    // =========================================================================
3818    // Heredoc / Inline Script Integration Tests (git_safety_guard-e7m)
3819    // =========================================================================
3820
3821    #[test]
3822    fn heredoc_scan_runs_before_keyword_quick_reject() {
3823        let config = default_config();
3824        let compiled = default_compiled_overrides();
3825        let allowlists = default_allowlists();
3826
3827        // This command would be ALLOWED by keyword quick-reject if we only looked for
3828        // unrelated pack keywords. The embedded JavaScript is still destructive and must
3829        // be analyzed and denied.
3830        let cmd = r#"node -e "require('child_process').execSync('rm -rf /')"""#;
3831        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
3832        assert!(result.is_denied());
3833
3834        let info = result.pattern_info.expect("deny must include pattern info");
3835        assert_eq!(info.source, MatchSource::HeredocAst);
3836        assert!(
3837            info.pack_id
3838                .as_deref()
3839                .is_some_and(|p| p.starts_with("heredoc."))
3840        );
3841    }
3842
3843    #[test]
3844    fn heredoc_triggers_inside_safe_string_arguments_do_not_scan_or_block() {
3845        let config = default_config();
3846        let compiled = default_compiled_overrides();
3847        let allowlists = default_allowlists();
3848
3849        // The commit message contains heredoc/inline-script trigger strings and a destructive
3850        // payload, but it's data-only (safe-string context). We must not treat it as executed.
3851        let cmd =
3852            r#"git commit -m "example: node -e \"require('child_process').execSync('rm -rf /')\"""#;
3853        let result = evaluate_command(cmd, &config, &["git"], &compiled, &allowlists);
3854        assert!(result.is_allowed());
3855    }
3856
3857    #[test]
3858    fn bd_notes_with_dangerous_text_is_allowed() {
3859        let config = default_config();
3860        let compiled = default_compiled_overrides();
3861        let allowlists = default_allowlists();
3862
3863        // Notes are documentation; dangerous text should not trigger blocking.
3864        let cmd = "bd create --notes This mentions rm -rf / but is just docs";
3865        let result = evaluate_command(cmd, &config, &["rm"], &compiled, &allowlists);
3866        assert!(result.is_allowed());
3867    }
3868
3869    #[test]
3870    fn bd_description_inline_code_is_blocked() {
3871        let config = default_config();
3872        let compiled = default_compiled_overrides();
3873        let allowlists = default_allowlists();
3874
3875        // Inline code in a data flag must still be evaluated and blocked.
3876        let cmd = r#"bd create --description "$(rm -rf /)""#;
3877        let result = evaluate_command(cmd, &config, &["rm"], &compiled, &allowlists);
3878        assert!(result.is_denied());
3879    }
3880
3881    #[test]
3882    fn echo_with_dangerous_text_is_allowed() {
3883        let config = default_config();
3884        let compiled = default_compiled_overrides();
3885        let allowlists = default_allowlists();
3886
3887        // echo arguments are data; should not be blocked by keyword matching.
3888        let cmd = r#"echo "rm -rf /""#;
3889        let result = evaluate_command(cmd, &config, &["rm"], &compiled, &allowlists);
3890        assert!(result.is_allowed());
3891    }
3892
3893    #[test]
3894    fn heredoc_commands_are_evaluated_and_block_when_severity_blocks_by_default() {
3895        let config = default_config();
3896        let compiled = default_compiled_overrides();
3897        let allowlists = default_allowlists();
3898
3899        // This command would be ALLOWED by keyword quick-reject if we only looked for unrelated
3900        // pack keywords. The embedded JavaScript still must be analyzed and denied.
3901        let cmd =
3902            "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
3903        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
3904        assert!(result.is_denied());
3905
3906        let info = result.pattern_info.expect("deny must include pattern info");
3907        assert_eq!(info.source, MatchSource::HeredocAst);
3908        assert_eq!(info.pack_id.as_deref(), Some("heredoc.javascript"));
3909        assert!(
3910            info.pattern_name
3911                .as_deref()
3912                .is_some_and(|p| p.starts_with("fs_rmsync")),
3913            "expected a fs_rmsync* heredoc rule, got {:?}",
3914            info.pattern_name
3915        );
3916    }
3917
3918    #[test]
3919    fn heredoc_commands_with_non_blocking_matches_are_allowed() {
3920        let config = default_config();
3921        let compiled = default_compiled_overrides();
3922        let allowlists = default_allowlists();
3923
3924        // Non-catastrophic recursive deletes are currently warn-only; evaluator should not block.
3925        let cmd =
3926            "node <<EOF\nconst fs = require('fs');\nfs.rmSync('./dist', { recursive: true });\nEOF";
3927        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
3928        assert!(result.is_allowed());
3929        assert!(result.pattern_info.is_none());
3930    }
3931
3932    #[test]
3933    fn heredoc_scanning_can_be_disabled_via_config() {
3934        let mut config = default_config();
3935        config.heredoc.enabled = Some(false);
3936        let compiled = default_compiled_overrides();
3937        let allowlists = default_allowlists();
3938
3939        let cmd =
3940            "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
3941        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
3942        assert!(result.is_allowed());
3943        assert!(result.pattern_info.is_none());
3944    }
3945
3946    #[test]
3947    fn heredoc_language_filter_can_skip_unwanted_languages() {
3948        let mut config = default_config();
3949        config.heredoc.languages = Some(vec!["python".to_string()]);
3950        let compiled = default_compiled_overrides();
3951        let allowlists = default_allowlists();
3952
3953        let cmd =
3954            "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
3955        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
3956        assert!(result.is_allowed());
3957        assert!(result.pattern_info.is_none());
3958    }
3959
3960    #[test]
3961    fn heredoc_allowlist_can_override_ast_denial() {
3962        let config = default_config();
3963        let compiled = default_compiled_overrides();
3964        let allowlists =
3965            project_allowlists_for_rule("heredoc.javascript:fs_rmsync.catastrophic", "local dev");
3966
3967        let cmd =
3968            "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
3969        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
3970        assert!(result.is_allowed());
3971
3972        let override_info = result
3973            .allowlist_override
3974            .as_ref()
3975            .expect("allowlist override metadata must be present");
3976        assert_eq!(override_info.layer, AllowlistLayer::Project);
3977        assert_eq!(override_info.reason, "local dev");
3978        assert_eq!(
3979            override_info.matched.pack_id.as_deref(),
3980            Some("heredoc.javascript")
3981        );
3982        assert_eq!(
3983            override_info.matched.pattern_name.as_deref(),
3984            Some("fs_rmsync.catastrophic")
3985        );
3986        assert_eq!(override_info.matched.source, MatchSource::HeredocAst);
3987    }
3988
3989    #[test]
3990    fn heredoc_content_allowlist_project_scope_skips_ast_scan() {
3991        let mut config = default_config();
3992        let cwd = std::env::current_dir().expect("current_dir must be available");
3993        let cwd_str = cwd.to_string_lossy().into_owned();
3994
3995        config.heredoc.allowlist = Some(crate::config::HeredocAllowlistConfig {
3996            projects: vec![crate::config::ProjectHeredocAllowlist {
3997                path: cwd_str,
3998                patterns: vec![crate::config::AllowedHeredocPattern {
3999                    language: Some("javascript".to_string()),
4000                    pattern: "fs.rmSync('/etc'".to_string(),
4001                    reason: "project allowlist".to_string(),
4002                }],
4003                content_hashes: vec![],
4004            }],
4005            ..Default::default()
4006        });
4007
4008        let compiled = config.overrides.compile();
4009        let allowlists = default_allowlists();
4010
4011        // This would normally be denied by heredoc AST rules (catastrophic path).
4012        let cmd =
4013            "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
4014        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
4015        assert!(
4016            result.is_allowed(),
4017            "project-scoped heredoc content allowlist should skip AST denial"
4018        );
4019    }
4020
4021    #[test]
4022    fn heredoc_content_allowlist_project_scope_does_not_match_other_projects() {
4023        let mut config = default_config();
4024
4025        config.heredoc.allowlist = Some(crate::config::HeredocAllowlistConfig {
4026            projects: vec![crate::config::ProjectHeredocAllowlist {
4027                path: "/definitely-not-a-prefix".to_string(),
4028                patterns: vec![crate::config::AllowedHeredocPattern {
4029                    language: Some("javascript".to_string()),
4030                    pattern: "fs.rmSync('/etc'".to_string(),
4031                    reason: "wrong project".to_string(),
4032                }],
4033                content_hashes: vec![],
4034            }],
4035            ..Default::default()
4036        });
4037
4038        let compiled = config.overrides.compile();
4039        let allowlists = default_allowlists();
4040
4041        let cmd =
4042            "node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
4043        let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
4044        assert!(
4045            result.is_denied(),
4046            "content allowlist should not apply when cwd is outside configured project scope"
4047        );
4048    }
4049
4050    #[test]
4051    fn heredoc_trigger_strings_inside_safe_string_arguments_do_not_scan_or_block() {
4052        let config = default_config();
4053        let compiled = default_compiled_overrides();
4054        let allowlists = default_allowlists();
4055
4056        // Commit messages can contain heredoc syntax as documentation; these are data-only.
4057        let cmd = r#"git commit -m "docs: example heredoc: cat <<EOF rm -rf / EOF""#;
4058        let result = evaluate_command(cmd, &config, &["git"], &compiled, &allowlists);
4059        assert!(result.is_allowed());
4060    }
4061
4062    #[test]
4063    fn test_evaluation_decision_equality() {
4064        assert_eq!(EvaluationDecision::Allow, EvaluationDecision::Allow);
4065        assert_eq!(EvaluationDecision::Deny, EvaluationDecision::Deny);
4066        assert_ne!(EvaluationDecision::Allow, EvaluationDecision::Deny);
4067    }
4068
4069    #[test]
4070    fn test_match_source_equality() {
4071        assert_eq!(MatchSource::ConfigOverride, MatchSource::ConfigOverride);
4072        assert_eq!(MatchSource::LegacyPattern, MatchSource::LegacyPattern);
4073        assert_eq!(MatchSource::Pack, MatchSource::Pack);
4074        assert_eq!(MatchSource::HeredocAst, MatchSource::HeredocAst);
4075        assert_ne!(MatchSource::ConfigOverride, MatchSource::Pack);
4076    }
4077
4078    // =========================================================================
4079    // Allowlist Override Tests (git_safety_guard-1gt.2.2)
4080    // =========================================================================
4081
4082    #[test]
4083    fn allowlist_hit_overrides_deny() {
4084        let config = default_config();
4085        let compiled = default_compiled_overrides();
4086        let allowlists = project_allowlists_for_rule("core.git:reset-hard", "local dev flow");
4087
4088        let result = evaluate_command(
4089            "git reset --hard",
4090            &config,
4091            &["git"],
4092            &compiled,
4093            &allowlists,
4094        );
4095        assert!(result.is_allowed());
4096        assert!(result.allowlist_override.is_some());
4097    }
4098
4099    #[test]
4100    fn allowlist_miss_does_not_change_decision() {
4101        let config = default_config();
4102        let compiled = default_compiled_overrides();
4103        let allowlists = project_allowlists_for_rule("core.git:reset-merge", "not this one");
4104
4105        let result = evaluate_command(
4106            "git reset --hard",
4107            &config,
4108            &["git"],
4109            &compiled,
4110            &allowlists,
4111        );
4112        assert!(result.is_denied());
4113        assert!(result.allowlist_override.is_none());
4114        assert_eq!(result.pack_id(), Some("core.git"));
4115    }
4116
4117    #[test]
4118    fn wildcard_allowlist_matches_only_within_pack() {
4119        let mut config = default_config();
4120        config.packs.enabled.push("strict_git".to_string());
4121
4122        let compiled = config.overrides.compile();
4123        let allowlists = project_allowlists_for_pack_wildcard("core.git", "allow all core.git");
4124
4125        // Matches core.git, should allow.
4126        let git_result = evaluate_command(
4127            "git reset --hard",
4128            &config,
4129            &["git", "rm"],
4130            &compiled,
4131            &allowlists,
4132        );
4133        assert!(git_result.is_allowed());
4134        assert!(git_result.allowlist_override.is_some());
4135
4136        // Matches core.filesystem, should still deny (wildcard is pack-scoped).
4137        let rm_result = evaluate_command(
4138            "rm -rf /etc",
4139            &config,
4140            &["git", "rm"],
4141            &compiled,
4142            &allowlists,
4143        );
4144        assert!(rm_result.is_denied());
4145        assert_eq!(rm_result.pack_id(), Some("core.filesystem"));
4146    }
4147
4148    #[test]
4149    fn allowlisting_one_rule_does_not_disable_other_packs() {
4150        let mut config = default_config();
4151        config.packs.enabled.push("strict_git".to_string());
4152
4153        let compiled = config.overrides.compile();
4154        let allowlists =
4155            project_allowlists_for_rule("core.git:push-force-long", "allow core force");
4156
4157        // This command matches BOTH core.git and strict_git.
4158        // We allowlisted core.git:push-force-long.
4159        // So core.git should ALLOW it.
4160        // But strict_git should still DENY it (as it checks later and isn't allowlisted).
4161        let result = evaluate_command(
4162            "git push origin main --force",
4163            &config,
4164            &["git"],
4165            &compiled,
4166            &allowlists,
4167        );
4168
4169        assert!(result.is_denied());
4170        // strict_git checks AFTER core.git.
4171        // core.git allows it (due to override).
4172        // strict_git blocks it.
4173        // So we expect strict_git.
4174        assert_eq!(result.pack_id(), Some("strict_git"));
4175        assert_eq!(
4176            result
4177                .pattern_info
4178                .as_ref()
4179                .unwrap()
4180                .pattern_name
4181                .as_deref(),
4182            Some("push-force-any") // strict_git rule name
4183        );
4184    }
4185
4186    // =========================================================================
4187    // Evaluator Behavior Tests (git_safety_guard-99e.3.5, git_safety_guard-1g6)
4188    // =========================================================================
4189    //
4190    // These tests verify evaluator behavior using real pack patterns.
4191    // Mock types removed per git_safety_guard-1g6.
4192
4193    /// Table-driven test: commands that should be ALLOWED.
4194    #[test]
4195    fn evaluator_allows_safe_commands() {
4196        let config = default_config();
4197        let compiled = default_compiled_overrides();
4198        let allowlists = default_allowlists();
4199        let keywords = &["git", "rm", "docker", "kubectl"];
4200
4201        let test_cases = [
4202            // Non-relevant commands (quick-rejected)
4203            "ls -la",
4204            "cargo build --release",
4205            "npm install",
4206            "echo hello",
4207            "cat /etc/passwd",
4208            // Empty command
4209            "",
4210        ];
4211
4212        for cmd in test_cases {
4213            let result = evaluate_command(cmd, &config, keywords, &compiled, &allowlists);
4214            assert!(
4215                result.is_allowed(),
4216                "Expected ALLOWED for {cmd:?}, got DENIED"
4217            );
4218        }
4219    }
4220
4221    /// Test: config allow overrides work correctly.
4222    #[test]
4223    fn evaluator_respects_config_allow_override() {
4224        let config = default_config();
4225        let compiled = default_compiled_overrides();
4226
4227        let tmp = std::env::temp_dir();
4228        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
4229        let path = tmp.join(format!(
4230            "dcg_allowlist_test_{}_{}.toml",
4231            std::process::id(),
4232            unique
4233        ));
4234
4235        let toml = r#"
4236            [[allow]]
4237            rule = "core.git:reset-hard"
4238            reason = "integration test"
4239        "#;
4240        std::fs::write(&path, toml).expect("write allowlist file");
4241
4242        let allowlists = LayeredAllowlist::load_from_paths(Some(path), None, None);
4243
4244        let result = evaluate_command(
4245            "git reset --hard",
4246            &config,
4247            &["git"],
4248            &compiled,
4249            &allowlists,
4250        );
4251        assert!(result.is_allowed());
4252        assert!(result.allowlist_override.is_some());
4253    }
4254
4255    #[test]
4256    fn config_block_override_wins_over_overlapping_allow_in_main_path() {
4257        let mut config = default_config();
4258        config.overrides.allow = vec![crate::config::AllowOverride::Simple(
4259            r"\bgit\s+reset\s+--hard\b".to_string(),
4260        )];
4261        config.overrides.block = vec![crate::config::BlockOverride {
4262            pattern: r"\bgit\s+reset\s+--hard\b".to_string(),
4263            reason: "explicit config block".to_string(),
4264        }];
4265
4266        let compiled = config.overrides.compile();
4267        let allowlists = default_allowlists();
4268        let result = evaluate_command(
4269            "git reset --hard",
4270            &config,
4271            &["git"],
4272            &compiled,
4273            &allowlists,
4274        );
4275
4276        assert!(result.is_denied());
4277        assert_eq!(result.reason(), Some("explicit config block"));
4278        assert_eq!(
4279            result.pattern_info.as_ref().unwrap().source,
4280            MatchSource::ConfigOverride
4281        );
4282    }
4283
4284    #[test]
4285    fn config_block_override_wins_over_overlapping_allow_in_legacy_path() {
4286        let mut config = default_config();
4287        config.overrides.allow = vec![crate::config::AllowOverride::Simple(
4288            r"\bgit\s+reset\s+--hard\b".to_string(),
4289        )];
4290        config.overrides.block = vec![crate::config::BlockOverride {
4291            pattern: r"\bgit\s+reset\s+--hard\b".to_string(),
4292            reason: "explicit config block".to_string(),
4293        }];
4294
4295        let compiled = config.overrides.compile();
4296        let allowlists = default_allowlists();
4297        let result = evaluate_command_with_legacy::<
4298            crate::packs::SafePattern,
4299            crate::packs::DestructivePattern,
4300        >(
4301            "git reset --hard",
4302            &config,
4303            &["git"],
4304            &compiled,
4305            &allowlists,
4306            &[],
4307            &[],
4308        );
4309
4310        assert!(result.is_denied());
4311        assert_eq!(result.reason(), Some("explicit config block"));
4312        assert_eq!(
4313            result.pattern_info.as_ref().unwrap().source,
4314            MatchSource::ConfigOverride
4315        );
4316    }
4317
4318    // =========================================================================
4319    // Match Span Tests (git_safety_guard-99e.2.4)
4320    // =========================================================================
4321
4322    #[test]
4323    fn truncate_preview_handles_utf8_safely() {
4324        // ASCII string
4325        let short = "hello";
4326        assert_eq!(super::truncate_preview(short, 10), "hello");
4327
4328        // Exactly at limit
4329        let exact = "hello";
4330        assert_eq!(super::truncate_preview(exact, 5), "hello");
4331
4332        // Over limit, needs truncation
4333        let long = "hello world";
4334        assert_eq!(super::truncate_preview(long, 8), "hello...");
4335
4336        // UTF-8 multibyte characters (should not break in middle of char)
4337        let japanese = "こんにちは世界"; // 7 chars, 21 bytes
4338        let truncated = super::truncate_preview(japanese, 5);
4339        assert!(truncated.ends_with("..."));
4340        // Should have 2 chars + "..."
4341        assert_eq!(truncated, "こん...");
4342
4343        // Emoji
4344        let emoji = "🔥🔥🔥🔥🔥"; // 5 emoji, 20 bytes
4345        let truncated_emoji = super::truncate_preview(emoji, 3);
4346        assert_eq!(truncated_emoji, "..."); // 0 chars + "..." since 3-3=0
4347    }
4348
4349    #[test]
4350    fn extract_match_preview_bounds_check() {
4351        let cmd = "rm -rf /important";
4352
4353        // Normal span
4354        let span = super::MatchSpan { start: 0, end: 2 };
4355        assert_eq!(super::extract_match_preview(cmd, &span), "rm");
4356
4357        // Span at end
4358        let span_end = super::MatchSpan { start: 7, end: 17 };
4359        assert_eq!(super::extract_match_preview(cmd, &span_end), "/important");
4360
4361        // Span beyond bounds (should clamp)
4362        let span_overflow = super::MatchSpan {
4363            start: 0,
4364            end: 1000,
4365        };
4366        assert_eq!(
4367            super::extract_match_preview(cmd, &span_overflow),
4368            "rm -rf /important"
4369        );
4370
4371        // Start beyond end (should return empty)
4372        let span_invalid = super::MatchSpan {
4373            start: 100,
4374            end: 50,
4375        };
4376        assert_eq!(super::extract_match_preview(cmd, &span_invalid), "");
4377    }
4378
4379    #[test]
4380    fn extract_match_preview_handles_invalid_utf8_boundaries() {
4381        // Multi-byte UTF-8: "日本" is 6 bytes (3 bytes per character)
4382        let cmd = "日本語"; // 9 bytes, 3 characters
4383
4384        // Valid boundaries (0, 3, 6, 9 are all valid)
4385        let valid_span = super::MatchSpan { start: 0, end: 3 };
4386        assert_eq!(super::extract_match_preview(cmd, &valid_span), "日");
4387
4388        // Invalid start boundary (byte 1 is middle of first char)
4389        // Should snap forward to byte 3 (start of second char)
4390        let invalid_start = super::MatchSpan { start: 1, end: 6 };
4391        assert_eq!(super::extract_match_preview(cmd, &invalid_start), "本");
4392
4393        // Invalid end boundary (byte 4 is middle of second char)
4394        // Should snap backward to byte 3 (end of first char)
4395        let invalid_end = super::MatchSpan { start: 0, end: 4 };
4396        assert_eq!(super::extract_match_preview(cmd, &invalid_end), "日");
4397
4398        // Both boundaries invalid - should still not panic
4399        let both_invalid = super::MatchSpan { start: 1, end: 4 };
4400        // start snaps to 3, end snaps to 3, so start >= end -> empty
4401        assert_eq!(super::extract_match_preview(cmd, &both_invalid), "");
4402
4403        // Span entirely within a character (start=1, end=2)
4404        // Both snap to boundaries, resulting in empty
4405        let within_char = super::MatchSpan { start: 1, end: 2 };
4406        assert_eq!(super::extract_match_preview(cmd, &within_char), "");
4407    }
4408
4409    #[test]
4410    fn heredoc_matches_include_span_info() {
4411        let mut config = default_config();
4412        config.packs.enabled.push("system.core".to_string());
4413        let compiled = config.overrides.compile();
4414        let allowlists = default_allowlists();
4415        let enabled_packs = config.enabled_pack_ids();
4416        let keywords_vec = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
4417        let keywords: Vec<&str> = keywords_vec.clone();
4418
4419        // Heredoc containing dangerous command
4420        let cmd = "cat <<'EOF'\nrm -rf /\nEOF";
4421
4422        let result = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
4423
4424        if result.is_denied() {
4425            if let Some(ref pattern_info) = result.pattern_info {
4426                // If there's a span, verify it's valid
4427                if let Some(span) = pattern_info.matched_span {
4428                    assert!(span.start <= span.end, "Span start should not exceed end");
4429                    assert!(
4430                        span.end <= cmd.len(),
4431                        "Span end should not exceed command length"
4432                    );
4433                    let matched = cmd.get(span.start..span.end).unwrap_or("");
4434                    assert!(
4435                        matched.contains("rm -rf /"),
4436                        "Matched span should point into heredoc content"
4437                    );
4438                }
4439            }
4440        }
4441    }
4442
4443    #[test]
4444    fn match_span_maps_to_original_with_wrappers() {
4445        let mut config = default_config();
4446        config.packs.enabled.push("core.git".to_string());
4447        let compiled = config.overrides.compile();
4448        let allowlists = default_allowlists();
4449        let enabled_packs = config.enabled_pack_ids();
4450        let keywords_vec = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
4451        let keywords: Vec<&str> = keywords_vec.clone();
4452
4453        let cmd = "sudo git reset --hard";
4454        let result = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
4455
4456        assert!(result.is_denied(), "Command should be denied");
4457        let pattern_info = result.pattern_info.expect("Expected pattern info");
4458        let span = pattern_info.matched_span.expect("Expected matched span");
4459        let matched = cmd.get(span.start..span.end).unwrap_or("");
4460        assert_eq!(matched, "git reset --hard");
4461    }
4462
4463    #[test]
4464    fn match_span_determinism() {
4465        let mut config = default_config();
4466        config.packs.enabled.push("system.core".to_string());
4467        let compiled = config.overrides.compile();
4468        let allowlists = default_allowlists();
4469        let enabled_packs = config.enabled_pack_ids();
4470        let keywords_vec = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
4471        let keywords: Vec<&str> = keywords_vec.clone();
4472
4473        let cmd = "rm -rf /";
4474
4475        // Run multiple times and verify same result
4476        let result1 = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
4477        let result2 = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
4478
4479        assert_eq!(result1.decision, result2.decision);
4480        assert_eq!(
4481            result1.pattern_info.as_ref().map(|p| p.matched_span),
4482            result2.pattern_info.as_ref().map(|p| p.matched_span),
4483            "Match span should be deterministic"
4484        );
4485        assert_eq!(
4486            result1
4487                .pattern_info
4488                .as_ref()
4489                .map(|p| p.matched_text_preview.as_ref()),
4490            result2
4491                .pattern_info
4492                .as_ref()
4493                .map(|p| p.matched_text_preview.as_ref()),
4494            "Match text preview should be deterministic"
4495        );
4496    }
4497
4498    // =========================================================================
4499    // Deadline / Fail-Open Tests (git_safety_guard-99e.14)
4500    // =========================================================================
4501
4502    mod deadline_tests {
4503        use super::*;
4504        use crate::perf::Deadline;
4505        use std::time::Duration;
4506
4507        fn test_heredoc_settings() -> crate::config::HeredocSettings {
4508            crate::config::Config::default().heredoc_settings()
4509        }
4510
4511        /// When deadline is already exceeded (zero duration), evaluation should fail-open immediately.
4512        #[test]
4513        fn exceeded_deadline_fails_open() {
4514            let compiled_overrides = default_compiled_overrides();
4515            let allowlists = default_allowlists();
4516            let heredoc_settings = test_heredoc_settings();
4517            let enabled_keywords: Vec<&str> = vec!["git", "rm"];
4518            let ordered_packs: Vec<String> = vec!["core.git".to_string()];
4519            let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
4520
4521            // Create a deadline with zero duration - should be immediately exceeded
4522            let deadline = Deadline::new(Duration::ZERO);
4523
4524            let result = evaluate_command_with_pack_order_deadline(
4525                "git reset --hard",
4526                &enabled_keywords,
4527                &ordered_packs,
4528                keyword_index.as_ref(),
4529                &compiled_overrides,
4530                &allowlists,
4531                &heredoc_settings,
4532                None,
4533                Some(&deadline),
4534            );
4535
4536            // Should allow due to budget exhaustion, not deny
4537            assert!(
4538                result.is_allowed(),
4539                "Zero-duration deadline should fail open and allow command"
4540            );
4541            assert!(
4542                result.skipped_due_to_budget,
4543                "Result should indicate it was skipped due to budget"
4544            );
4545        }
4546
4547        /// Normal deadline should allow evaluation to proceed.
4548        #[test]
4549        fn normal_deadline_allows_evaluation() {
4550            let compiled_overrides = default_compiled_overrides();
4551            let allowlists = default_allowlists();
4552            let heredoc_settings = test_heredoc_settings();
4553            let enabled_keywords: Vec<&str> = vec!["git", "rm"];
4554            let ordered_packs: Vec<String> = vec!["core.git".to_string()];
4555            let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
4556
4557            // Create a generous deadline
4558            let deadline = Deadline::new(Duration::from_secs(10));
4559
4560            let result = evaluate_command_with_pack_order_deadline(
4561                "git reset --hard",
4562                &enabled_keywords,
4563                &ordered_packs,
4564                keyword_index.as_ref(),
4565                &compiled_overrides,
4566                &allowlists,
4567                &heredoc_settings,
4568                None,
4569                Some(&deadline),
4570            );
4571
4572            // Should deny the destructive command normally
4573            assert!(
4574                result.is_denied(),
4575                "Normal deadline should allow evaluation to proceed and deny destructive command"
4576            );
4577            assert!(
4578                !result.skipped_due_to_budget,
4579                "Result should not indicate budget skip"
4580            );
4581        }
4582
4583        /// No deadline (None) should allow evaluation to proceed.
4584        #[test]
4585        fn no_deadline_allows_evaluation() {
4586            let compiled_overrides = default_compiled_overrides();
4587            let allowlists = default_allowlists();
4588            let heredoc_settings = test_heredoc_settings();
4589            let enabled_keywords: Vec<&str> = vec!["git", "rm"];
4590            let ordered_packs: Vec<String> = vec!["core.git".to_string()];
4591            let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
4592
4593            let result = evaluate_command_with_pack_order_deadline(
4594                "git reset --hard",
4595                &enabled_keywords,
4596                &ordered_packs,
4597                keyword_index.as_ref(),
4598                &compiled_overrides,
4599                &allowlists,
4600                &heredoc_settings,
4601                None,
4602                None, // No deadline
4603            );
4604
4605            // Should deny the destructive command normally
4606            assert!(
4607                result.is_denied(),
4608                "No deadline should allow evaluation to proceed and deny destructive command"
4609            );
4610            assert!(
4611                !result.skipped_due_to_budget,
4612                "Result should not indicate budget skip"
4613            );
4614        }
4615
4616        /// Safe commands should be allowed even with tight deadline.
4617        #[test]
4618        fn safe_command_with_deadline() {
4619            let compiled_overrides = default_compiled_overrides();
4620            let allowlists = default_allowlists();
4621            let heredoc_settings = test_heredoc_settings();
4622            let enabled_keywords: Vec<&str> = vec!["git", "rm"];
4623            let ordered_packs: Vec<String> = vec!["core.git".to_string()];
4624            let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
4625
4626            // Generous deadline for safe command
4627            let deadline = Deadline::new(Duration::from_secs(10));
4628
4629            let result = evaluate_command_with_pack_order_deadline(
4630                "git status",
4631                &enabled_keywords,
4632                &ordered_packs,
4633                keyword_index.as_ref(),
4634                &compiled_overrides,
4635                &allowlists,
4636                &heredoc_settings,
4637                None,
4638                Some(&deadline),
4639            );
4640
4641            // Should allow safe command
4642            assert!(result.is_allowed(), "Safe command should be allowed");
4643            assert!(
4644                !result.skipped_due_to_budget,
4645                "Safe command should not trigger budget skip"
4646            );
4647        }
4648
4649        /// Test the `allowed_due_to_budget()` result structure.
4650        #[test]
4651        fn allowed_due_to_budget_structure() {
4652            let result = EvaluationResult::allowed_due_to_budget();
4653
4654            assert!(result.is_allowed());
4655            assert!(!result.is_denied());
4656            assert!(result.skipped_due_to_budget);
4657            assert!(result.pattern_info.is_none());
4658            assert!(result.allowlist_override.is_none());
4659            assert!(result.effective_mode.is_none());
4660        }
4661
4662        /// Safe pattern matching must respect deadline — a burst of backtracking
4663        /// safe patterns should not run unbounded past the deadline.
4664        #[test]
4665        fn deadline_enforced_during_safe_pattern_matching() {
4666            use crate::packs::Pack;
4667
4668            let mut safe_patterns = Vec::new();
4669            for i in 0..20 {
4670                safe_patterns.push(crate::packs::SafePattern {
4671                    regex: crate::packs::regex_engine::LazyCompiledRegex::new(
4672                        // Lookahead forces backtracking engine; nested quantifiers
4673                        // cause worst-case backtracking on the adversarial input below.
4674                        if i % 2 == 0 {
4675                            r"(?=.*safe_cmd)(\w+\s+)*\w+"
4676                        } else {
4677                            r"(?=.*no_match_ever)(\w+\s+)*\w+"
4678                        },
4679                    ),
4680                    name: "adversarial_safe",
4681                });
4682            }
4683            let pack = Pack {
4684                id: "test.adversarial".to_string(),
4685                name: "adversarial",
4686                description: "test pack",
4687                keywords: &["rm"],
4688                safe_patterns,
4689                destructive_patterns: vec![crate::destructive_pattern!(
4690                    "adversarial_rm",
4691                    r"rm\b",
4692                    "test destructive",
4693                    High
4694                )],
4695                keyword_matcher: None,
4696                safe_regex_set: None,
4697                safe_regex_set_is_complete: false,
4698            };
4699
4700            // Craft adversarial input: keyword match + repetitive whitespace tokens
4701            // that cause exponential backtracking in (\w+\s+)*\w+
4702            let adversarial = format!("rm {}", "a ".repeat(30));
4703
4704            // Zero-duration deadline should cause safe matching to bail out
4705            let deadline = Deadline::new(Duration::ZERO);
4706            let result = pack.matches_safe_with_deadline(&adversarial, Some(&deadline));
4707            assert!(
4708                !result,
4709                "Should bail out (return false) when deadline exceeded during safe pattern scan"
4710            );
4711        }
4712
4713        /// Post-find deadline check: after a slow destructive regex.find(), the
4714        /// evaluator should bail before processing the match result.
4715        #[test]
4716        fn deadline_enforced_after_destructive_regex_find() {
4717            let compiled_overrides = default_compiled_overrides();
4718            let allowlists = default_allowlists();
4719            let heredoc_settings = test_heredoc_settings();
4720            let enabled_keywords: Vec<&str> = vec!["rm"];
4721            let ordered_packs: Vec<String> = vec!["core.filesystem".to_string()];
4722            let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
4723
4724            // Deadline that's already expired
4725            let deadline = Deadline::new(Duration::ZERO);
4726            std::thread::sleep(Duration::from_millis(1));
4727
4728            let result = evaluate_command_with_pack_order_deadline(
4729                "rm -rf /important",
4730                &enabled_keywords,
4731                &ordered_packs,
4732                keyword_index.as_ref(),
4733                &compiled_overrides,
4734                &allowlists,
4735                &heredoc_settings,
4736                None,
4737                Some(&deadline),
4738            );
4739
4740            assert!(result.is_allowed());
4741            assert!(result.skipped_due_to_budget);
4742        }
4743
4744        /// With a generous deadline, destructive commands should still be denied
4745        /// even with backtracking patterns present — deadline enforcement must
4746        /// not swallow legitimate matches.
4747        #[test]
4748        fn generous_deadline_still_denies_destructive() {
4749            let compiled_overrides = default_compiled_overrides();
4750            let allowlists = default_allowlists();
4751            let heredoc_settings = test_heredoc_settings();
4752            let enabled_keywords: Vec<&str> = vec!["git"];
4753            let ordered_packs: Vec<String> = vec!["core.git".to_string()];
4754            let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
4755
4756            let deadline = Deadline::new(Duration::from_secs(30));
4757
4758            let result = evaluate_command_with_pack_order_deadline(
4759                "git reset --hard HEAD~5",
4760                &enabled_keywords,
4761                &ordered_packs,
4762                keyword_index.as_ref(),
4763                &compiled_overrides,
4764                &allowlists,
4765                &heredoc_settings,
4766                None,
4767                Some(&deadline),
4768            );
4769
4770            assert!(
4771                result.is_denied(),
4772                "Generous deadline should still deny destructive commands"
4773            );
4774            assert!(!result.skipped_due_to_budget);
4775        }
4776    }
4777
4778    #[test]
4779    fn integration_allowlist_file_overrides_deny() {
4780        let config = default_config();
4781        let compiled = default_compiled_overrides();
4782
4783        let tmp = std::env::temp_dir();
4784        let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
4785        let path = tmp.join(format!(
4786            "dcg_allowlist_test_{}_{}.toml",
4787            std::process::id(),
4788            unique
4789        ));
4790
4791        let toml = r#"
4792            [[allow]]
4793            rule = "core.git:reset-hard"
4794            reason = "integration test"
4795        "#;
4796        std::fs::write(&path, toml).expect("write allowlist file");
4797
4798        let allowlists = LayeredAllowlist::load_from_paths(Some(path), None, None);
4799
4800        let result = evaluate_command(
4801            "git reset --hard",
4802            &config,
4803            &["git"],
4804            &compiled,
4805            &allowlists,
4806        );
4807        assert!(result.is_allowed());
4808        assert!(result.allowlist_override.is_some());
4809    }
4810
4811    // =========================================================================
4812    // Confidence Tiering Tests (git_safety_guard-oien.2.2)
4813    // =========================================================================
4814    //
4815    // These tests verify that Medium/Low severity patterns are evaluated (not skipped)
4816    // and the evaluator returns Deny results that the policy layer can convert to Warn/Log.
4817
4818    #[test]
4819    fn medium_severity_patterns_are_evaluated() {
4820        // Test that Medium severity patterns are matched and return Deny results.
4821        // The policy layer in main.rs will convert these to Warn mode.
4822        let mut config = default_config();
4823        config.packs.enabled.push("containers.docker".to_string());
4824        let compiled = config.overrides.compile();
4825        let allowlists = default_allowlists();
4826
4827        // docker image prune is a Medium severity pattern
4828        let result = evaluate_command(
4829            "docker image prune",
4830            &config,
4831            &["docker"],
4832            &compiled,
4833            &allowlists,
4834        );
4835
4836        // Evaluator should return Deny (policy layer converts to Warn)
4837        assert!(
4838            result.is_denied(),
4839            "Medium severity pattern should be evaluated and return Deny"
4840        );
4841
4842        // Verify severity is Medium
4843        let info = result
4844            .pattern_info
4845            .as_ref()
4846            .expect("should have pattern info");
4847        assert_eq!(
4848            info.severity,
4849            Some(crate::packs::Severity::Medium),
4850            "Pattern should have Medium severity"
4851        );
4852        assert_eq!(info.pack_id.as_deref(), Some("containers.docker"));
4853        assert_eq!(info.pattern_name.as_deref(), Some("image-prune"));
4854    }
4855
4856    #[test]
4857    fn medium_severity_git_patterns_are_evaluated() {
4858        // Test git branch -D and stash drop (both Medium severity)
4859        let config = default_config();
4860        let compiled = config.overrides.compile();
4861        let allowlists = default_allowlists();
4862
4863        // git branch -D is Medium severity
4864        let branch_result = evaluate_command(
4865            "git branch -D feature-branch",
4866            &config,
4867            &["git"],
4868            &compiled,
4869            &allowlists,
4870        );
4871        assert!(
4872            branch_result.is_denied(),
4873            "git branch -D should be evaluated"
4874        );
4875        let branch_info = branch_result.pattern_info.as_ref().unwrap();
4876        assert_eq!(branch_info.severity, Some(crate::packs::Severity::Medium));
4877        assert_eq!(
4878            branch_info.pattern_name.as_deref(),
4879            Some("branch-force-delete")
4880        );
4881
4882        // git stash drop is Medium severity
4883        let stash_result = evaluate_command(
4884            "git stash drop stash@{0}",
4885            &config,
4886            &["git"],
4887            &compiled,
4888            &allowlists,
4889        );
4890        assert!(
4891            stash_result.is_denied(),
4892            "git stash drop should be evaluated"
4893        );
4894        let stash_info = stash_result.pattern_info.as_ref().unwrap();
4895        assert_eq!(stash_info.severity, Some(crate::packs::Severity::Medium));
4896        assert_eq!(stash_info.pattern_name.as_deref(), Some("stash-drop"));
4897    }
4898
4899    #[test]
4900    fn critical_patterns_still_return_critical_severity() {
4901        // Ensure Critical patterns are unchanged
4902        let config = default_config();
4903        let compiled = config.overrides.compile();
4904        let allowlists = default_allowlists();
4905
4906        // git reset --hard is Critical
4907        let result = evaluate_command(
4908            "git reset --hard",
4909            &config,
4910            &["git"],
4911            &compiled,
4912            &allowlists,
4913        );
4914        assert!(result.is_denied());
4915        let info = result.pattern_info.as_ref().unwrap();
4916        assert_eq!(
4917            info.severity,
4918            Some(crate::packs::Severity::Critical),
4919            "git reset --hard should remain Critical severity"
4920        );
4921
4922        // git stash clear is Critical (vs stash drop which is Medium)
4923        let clear_result =
4924            evaluate_command("git stash clear", &config, &["git"], &compiled, &allowlists);
4925        assert!(clear_result.is_denied());
4926        let clear_info = clear_result.pattern_info.as_ref().unwrap();
4927        assert_eq!(
4928            clear_info.severity,
4929            Some(crate::packs::Severity::Critical),
4930            "git stash clear should remain Critical severity"
4931        );
4932    }
4933
4934    #[test]
4935    fn policy_converts_medium_to_warn_mode() {
4936        // Test the policy layer correctly converts Medium severity to Warn mode.
4937        // This simulates what main.rs does after receiving the evaluation result.
4938        let policy = crate::config::PolicyConfig::default();
4939
4940        // Medium severity should resolve to Warn mode
4941        let mode = policy.resolve_mode(
4942            Some("containers.docker"),
4943            Some("image-prune"),
4944            Some(crate::packs::Severity::Medium),
4945        );
4946        assert_eq!(
4947            mode,
4948            crate::packs::DecisionMode::Warn,
4949            "Medium severity should default to Warn mode"
4950        );
4951
4952        // Critical severity should resolve to Deny mode
4953        let critical_mode = policy.resolve_mode(
4954            Some("core.git"),
4955            Some("reset-hard"),
4956            Some(crate::packs::Severity::Critical),
4957        );
4958        assert_eq!(
4959            critical_mode,
4960            crate::packs::DecisionMode::Deny,
4961            "Critical severity should always be Deny mode"
4962        );
4963    }
4964
4965    // =========================================================================
4966    // UTF-8 Safe Windowing Tests (git_safety_guard-jpfm.2)
4967    // =========================================================================
4968
4969    #[test]
4970    fn window_command_short_command_unchanged() {
4971        let cmd = "git reset --hard";
4972        let span = MatchSpan { start: 0, end: 16 };
4973        let result = window_command(cmd, &span, 80);
4974
4975        assert_eq!(result.display, cmd);
4976        assert!(result.adjusted_span.is_some());
4977        let adj = result.adjusted_span.unwrap();
4978        assert_eq!(adj.start, 0);
4979        assert_eq!(adj.end, 16);
4980    }
4981
4982    #[test]
4983    fn window_command_long_command_with_ellipsis() {
4984        // Create a long command with match in the middle
4985        let prefix = "a".repeat(50);
4986        let suffix = "b".repeat(50);
4987        let match_text = "git reset --hard";
4988        let cmd = format!("{prefix}{match_text}{suffix}");
4989        let span = MatchSpan {
4990            start: 50,
4991            end: 50 + 16,
4992        };
4993
4994        let result = window_command(&cmd, &span, 40);
4995
4996        // Should have ellipsis on both sides
4997        assert!(result.display.starts_with("..."));
4998        assert!(result.display.ends_with("..."));
4999        assert!(result.display.contains("git reset --hard"));
5000
5001        // Adjusted span should point to the match within the windowed string
5002        let adj = result.adjusted_span.expect("Should have adjusted span");
5003        let windowed_match: String = result
5004            .display
5005            .chars()
5006            .skip(adj.start)
5007            .take(adj.end - adj.start)
5008            .collect();
5009        assert_eq!(windowed_match, "git reset --hard");
5010    }
5011
5012    #[test]
5013    fn window_command_match_at_start() {
5014        let match_text = "rm -rf /";
5015        let suffix = "x".repeat(100);
5016        let cmd = format!("{match_text}{suffix}");
5017        let span = MatchSpan { start: 0, end: 8 };
5018
5019        let result = window_command(&cmd, &span, 40);
5020
5021        // Should NOT have left ellipsis, but should have right
5022        assert!(!result.display.starts_with("..."));
5023        assert!(result.display.ends_with("..."));
5024        assert!(result.display.contains("rm -rf /"));
5025
5026        let adj = result.adjusted_span.expect("Should have adjusted span");
5027        assert_eq!(adj.start, 0);
5028    }
5029
5030    #[test]
5031    fn window_command_match_at_end() {
5032        let prefix = "y".repeat(100);
5033        let match_text = "rm -rf /";
5034        let cmd = format!("{prefix}{match_text}");
5035        let span = MatchSpan {
5036            start: 100,
5037            end: 108,
5038        };
5039
5040        let result = window_command(&cmd, &span, 40);
5041
5042        // Should have left ellipsis, but NOT right
5043        assert!(result.display.starts_with("..."));
5044        assert!(!result.display.ends_with("..."));
5045        assert!(result.display.contains("rm -rf /"));
5046    }
5047
5048    #[test]
5049    fn window_command_utf8_multibyte_chars() {
5050        // Test with UTF-8 multibyte characters (emoji)
5051        let cmd = "echo 🎉🎊🎈 && rm -rf / && echo done";
5052        // "rm -rf /" starts at byte position after "echo 🎉🎊🎈 && "
5053        // Each emoji is 4 bytes, so: "echo " (5) + 3*4 (12) + " && " (4) = 21 bytes
5054        let span = MatchSpan { start: 21, end: 29 }; // "rm -rf /"
5055
5056        let result = window_command(cmd, &span, 50);
5057
5058        assert!(result.display.contains("rm -rf /"));
5059        assert!(result.adjusted_span.is_some());
5060    }
5061
5062    #[test]
5063    fn window_command_invalid_span_handles_gracefully() {
5064        let cmd = "short";
5065        let span = MatchSpan {
5066            start: 100,
5067            end: 200,
5068        }; // Way past end
5069
5070        let result = window_command(cmd, &span, 80);
5071
5072        // Should return full command but no span
5073        assert_eq!(result.display, "short");
5074        assert!(result.adjusted_span.is_none());
5075    }
5076
5077    // =============================================================================
5078    // Git branch-aware strictness tests
5079    // =============================================================================
5080
5081    mod branch_strictness_tests {
5082        use super::*;
5083        use crate::config::{GitAwarenessConfig, StrictnessLevel};
5084        use crate::packs::Severity;
5085        use std::path::Path;
5086        use std::process::Command;
5087
5088        fn config_with_git_awareness(enabled: bool) -> Config {
5089            let mut config = Config::default();
5090            config.git_awareness.enabled = enabled;
5091            config
5092        }
5093
5094        fn create_deny_result_with_severity(severity: Severity) -> EvaluationResult {
5095            EvaluationResult {
5096                decision: EvaluationDecision::Deny,
5097                pattern_info: Some(PatternMatch {
5098                    pack_id: Some("test.pack".to_string()),
5099                    pattern_name: Some("test_pattern".to_string()),
5100                    severity: Some(severity),
5101                    reason: "Test reason".to_string(),
5102                    source: MatchSource::Pack,
5103                    matched_span: None,
5104                    matched_text_preview: None,
5105                    explanation: None,
5106                    suggestions: &[],
5107                }),
5108                allowlist_override: None,
5109                effective_mode: Some(crate::packs::DecisionMode::Deny),
5110                skipped_due_to_budget: false,
5111                branch_context: None,
5112                session_occurrence: None,
5113                graduated_response: None,
5114                bypass_method: None,
5115            }
5116        }
5117
5118        fn run_git(repo_path: &Path, args: &[&str]) {
5119            let output = Command::new("git")
5120                .current_dir(repo_path)
5121                .args(args)
5122                .output()
5123                .expect("failed to run git command");
5124            assert!(
5125                output.status.success(),
5126                "git {:?} failed: {}",
5127                args,
5128                String::from_utf8_lossy(&output.stderr)
5129            );
5130        }
5131
5132        fn init_git_repo(repo_path: &Path, branch: &str) {
5133            run_git(repo_path, &["init"]);
5134            run_git(
5135                repo_path,
5136                &["config", "user.email", "dcg-tests@example.com"],
5137            );
5138            run_git(repo_path, &["config", "user.name", "DCG Tests"]);
5139            run_git(repo_path, &["checkout", "-b", branch]);
5140        }
5141
5142        fn init_git_repo_detached(repo_path: &Path) {
5143            init_git_repo(repo_path, "main");
5144            // Need at least one commit to detach a HEAD that points anywhere.
5145            std::fs::write(repo_path.join("seed"), "seed").expect("seed file");
5146            run_git(repo_path, &["add", "seed"]);
5147            run_git(repo_path, &["commit", "-m", "seed"]);
5148            run_git(repo_path, &["checkout", "--detach", "HEAD"]);
5149        }
5150
5151        #[test]
5152        fn disabled_git_awareness_returns_unchanged_result() {
5153            let config = config_with_git_awareness(false);
5154            let result = create_deny_result_with_severity(Severity::High);
5155
5156            let modified = apply_branch_strictness(result, &config, None);
5157
5158            // Decision should remain Deny
5159            assert_eq!(modified.decision, EvaluationDecision::Deny);
5160            // No branch context should be set
5161            assert!(modified.branch_context.is_none());
5162        }
5163
5164        #[test]
5165        fn strictness_level_should_block_checks_critical() {
5166            assert!(StrictnessLevel::Critical.should_block(Severity::Critical));
5167            assert!(!StrictnessLevel::Critical.should_block(Severity::High));
5168            assert!(!StrictnessLevel::Critical.should_block(Severity::Medium));
5169            assert!(!StrictnessLevel::Critical.should_block(Severity::Low));
5170        }
5171
5172        #[test]
5173        fn strictness_level_should_block_checks_high() {
5174            assert!(StrictnessLevel::High.should_block(Severity::Critical));
5175            assert!(StrictnessLevel::High.should_block(Severity::High));
5176            assert!(!StrictnessLevel::High.should_block(Severity::Medium));
5177            assert!(!StrictnessLevel::High.should_block(Severity::Low));
5178        }
5179
5180        #[test]
5181        fn strictness_level_should_block_checks_medium() {
5182            assert!(StrictnessLevel::Medium.should_block(Severity::Critical));
5183            assert!(StrictnessLevel::Medium.should_block(Severity::High));
5184            assert!(StrictnessLevel::Medium.should_block(Severity::Medium));
5185            assert!(!StrictnessLevel::Medium.should_block(Severity::Low));
5186        }
5187
5188        #[test]
5189        fn strictness_level_should_block_checks_all() {
5190            assert!(StrictnessLevel::All.should_block(Severity::Critical));
5191            assert!(StrictnessLevel::All.should_block(Severity::High));
5192            assert!(StrictnessLevel::All.should_block(Severity::Medium));
5193            assert!(StrictnessLevel::All.should_block(Severity::Low));
5194        }
5195
5196        #[test]
5197        fn git_awareness_config_is_protected_branch() {
5198            let config = GitAwarenessConfig {
5199                enabled: true,
5200                protected_branches: vec!["main".to_string(), "master".to_string()],
5201                protected_strictness: StrictnessLevel::All,
5202                relaxed_branches: vec![],
5203                relaxed_strictness: StrictnessLevel::Critical,
5204                default_strictness: StrictnessLevel::High,
5205                detached_head_strictness: StrictnessLevel::All,
5206                relaxed_disabled_packs: vec![],
5207                show_branch_in_output: true,
5208                warn_if_not_git: false,
5209            };
5210
5211            assert!(config.is_protected_branch(Some("main")));
5212            assert!(config.is_protected_branch(Some("master")));
5213            assert!(!config.is_protected_branch(Some("feature/test")));
5214            assert!(!config.is_protected_branch(None));
5215        }
5216
5217        #[test]
5218        fn git_awareness_config_is_relaxed_branch_with_glob() {
5219            let config = GitAwarenessConfig {
5220                enabled: true,
5221                protected_branches: vec![],
5222                protected_strictness: StrictnessLevel::All,
5223                relaxed_branches: vec!["feature/*".to_string(), "experiment/*".to_string()],
5224                relaxed_strictness: StrictnessLevel::Critical,
5225                default_strictness: StrictnessLevel::High,
5226                detached_head_strictness: StrictnessLevel::All,
5227                relaxed_disabled_packs: vec![],
5228                show_branch_in_output: true,
5229                warn_if_not_git: false,
5230            };
5231
5232            assert!(config.is_relaxed_branch(Some("feature/my-feature")));
5233            assert!(config.is_relaxed_branch(Some("experiment/test")));
5234            assert!(!config.is_relaxed_branch(Some("main")));
5235            assert!(!config.is_relaxed_branch(None));
5236        }
5237
5238        #[test]
5239        fn git_awareness_config_strictness_for_branch() {
5240            let config = GitAwarenessConfig {
5241                enabled: true,
5242                protected_branches: vec!["main".to_string()],
5243                protected_strictness: StrictnessLevel::All,
5244                relaxed_branches: vec!["feature/*".to_string()],
5245                relaxed_strictness: StrictnessLevel::Critical,
5246                default_strictness: StrictnessLevel::High,
5247                detached_head_strictness: StrictnessLevel::All,
5248                relaxed_disabled_packs: vec![],
5249                show_branch_in_output: true,
5250                warn_if_not_git: false,
5251            };
5252
5253            // Protected branch gets protected strictness
5254            assert_eq!(
5255                config.strictness_for_branch(Some("main")),
5256                StrictnessLevel::All
5257            );
5258            // Relaxed branch gets relaxed strictness
5259            assert_eq!(
5260                config.strictness_for_branch(Some("feature/test")),
5261                StrictnessLevel::Critical
5262            );
5263            // Other branch gets default strictness
5264            assert_eq!(
5265                config.strictness_for_branch(Some("develop")),
5266                StrictnessLevel::High
5267            );
5268            // No branch gets default strictness
5269            assert_eq!(config.strictness_for_branch(None), StrictnessLevel::High);
5270        }
5271
5272        #[test]
5273        fn git_awareness_not_in_repo_uses_default_strictness() {
5274            // When not in a git repo, evaluation should use default strictness
5275            // and not panic or error. This tests graceful degradation.
5276            let mut config = Config::default();
5277            config.git_awareness.enabled = true;
5278            config.git_awareness.warn_if_not_git = false; // Don't emit warning in tests
5279
5280            // Create a result that would normally be blocked
5281            let result = EvaluationResult {
5282                decision: EvaluationDecision::Deny,
5283                pattern_info: Some(PatternMatch {
5284                    reason: "test reason".to_string(),
5285                    pattern_name: Some("test-pattern".to_string()),
5286                    pack_id: Some("test.pack".to_string()),
5287                    severity: Some(crate::packs::Severity::High),
5288                    source: MatchSource::Pack,
5289                    matched_span: None,
5290                    matched_text_preview: None,
5291                    explanation: None,
5292                    suggestions: &[],
5293                }),
5294                allowlist_override: None,
5295                branch_context: None,
5296                effective_mode: None,
5297                skipped_due_to_budget: false,
5298                session_occurrence: None,
5299                graduated_response: None,
5300                bypass_method: None,
5301            };
5302
5303            // Applying branch strictness at a non-git path should return unchanged result
5304            let temp_dir = std::env::temp_dir();
5305            // Create a unique subdir that is definitely not a git repo
5306            let unique_dir = temp_dir.join(format!("dcg_test_{}", std::process::id()));
5307            let _ = std::fs::create_dir_all(&unique_dir);
5308
5309            // Apply branch strictness at the temp path (not a git repo)
5310            let modified_result =
5311                apply_branch_strictness(result.clone(), &config, Some(unique_dir.as_path()));
5312
5313            // Result should be unchanged when not in a git repo (graceful degradation)
5314            assert_eq!(modified_result.decision, result.decision);
5315            assert!(
5316                modified_result.branch_context.is_none(),
5317                "Branch context should be None when not in a git repo"
5318            );
5319
5320            // Clean up
5321            let _ = std::fs::remove_dir(&unique_dir);
5322        }
5323
5324        #[test]
5325        fn git_awareness_warn_if_not_git_config() {
5326            // Test that the warn_if_not_git config option exists and can be set
5327            let mut config = Config::default();
5328
5329            // Default should be false
5330            assert!(
5331                !config.git_awareness.warn_if_not_git,
5332                "warn_if_not_git should default to false"
5333            );
5334
5335            // Should be settable
5336            config.git_awareness.warn_if_not_git = true;
5337            assert!(config.git_awareness.warn_if_not_git);
5338        }
5339
5340        #[test]
5341        fn relaxed_branch_can_downgrade_deny_to_allow() {
5342            let temp = tempfile::tempdir().expect("tempdir");
5343            init_git_repo(temp.path(), "feature/relaxed");
5344
5345            let mut config = Config::default();
5346            config.git_awareness.enabled = true;
5347            config.git_awareness.protected_branches = vec!["main".to_string()];
5348            config.git_awareness.protected_strictness = StrictnessLevel::All;
5349            config.git_awareness.relaxed_branches = vec!["feature/*".to_string()];
5350            config.git_awareness.relaxed_strictness = StrictnessLevel::Critical;
5351            config.git_awareness.default_strictness = StrictnessLevel::High;
5352            config.git_awareness.warn_if_not_git = false;
5353
5354            let result = create_deny_result_with_severity(Severity::Low);
5355            let modified = apply_branch_strictness(result, &config, Some(temp.path()));
5356
5357            assert_eq!(modified.decision, EvaluationDecision::Allow);
5358
5359            let branch_context = modified
5360                .branch_context
5361                .expect("branch context should be populated");
5362            assert_eq!(
5363                branch_context.branch_name.as_deref(),
5364                Some("feature/relaxed")
5365            );
5366            assert!(!branch_context.is_protected);
5367            assert!(branch_context.is_relaxed);
5368            assert_eq!(branch_context.strictness, StrictnessLevel::Critical);
5369            assert!(branch_context.affected_decision);
5370        }
5371
5372        #[test]
5373        fn protected_branch_keeps_deny_for_blocked_severity() {
5374            let temp = tempfile::tempdir().expect("tempdir");
5375            init_git_repo(temp.path(), "main");
5376
5377            let mut config = Config::default();
5378            config.git_awareness.enabled = true;
5379            config.git_awareness.protected_branches = vec!["main".to_string()];
5380            config.git_awareness.protected_strictness = StrictnessLevel::All;
5381            config.git_awareness.relaxed_branches = vec!["feature/*".to_string()];
5382            config.git_awareness.relaxed_strictness = StrictnessLevel::Critical;
5383            config.git_awareness.default_strictness = StrictnessLevel::High;
5384            config.git_awareness.warn_if_not_git = false;
5385
5386            let result = create_deny_result_with_severity(Severity::High);
5387            let modified = apply_branch_strictness(result, &config, Some(temp.path()));
5388
5389            assert_eq!(modified.decision, EvaluationDecision::Deny);
5390
5391            let branch_context = modified
5392                .branch_context
5393                .expect("branch context should be populated");
5394            assert_eq!(branch_context.branch_name.as_deref(), Some("main"));
5395            assert!(branch_context.is_protected);
5396            assert!(!branch_context.is_relaxed);
5397            assert_eq!(branch_context.strictness, StrictnessLevel::All);
5398            assert!(!branch_context.affected_decision);
5399        }
5400
5401        #[test]
5402        fn detached_head_uses_detached_head_strictness_not_default() {
5403            // Detached HEAD typically signals rebase / bisect / checkout-tag.
5404            // With detached_head_strictness=All and a Low-severity result,
5405            // the result must stay Deny (the strictest knob applies),
5406            // even though default_strictness=Critical would have allowed it.
5407            let temp = tempfile::tempdir().expect("tempdir");
5408            init_git_repo_detached(temp.path());
5409
5410            let mut config = Config::default();
5411            config.git_awareness.enabled = true;
5412            config.git_awareness.protected_branches = vec!["main".to_string()];
5413            config.git_awareness.protected_strictness = StrictnessLevel::All;
5414            config.git_awareness.relaxed_branches = vec!["feature/*".to_string()];
5415            config.git_awareness.relaxed_strictness = StrictnessLevel::Critical;
5416            // default_strictness is Critical (would NOT block Low) — proves
5417            // detached_head_strictness overrides default, not the other way.
5418            config.git_awareness.default_strictness = StrictnessLevel::Critical;
5419            config.git_awareness.detached_head_strictness = StrictnessLevel::All;
5420            config.git_awareness.warn_if_not_git = false;
5421
5422            let result = create_deny_result_with_severity(Severity::Low);
5423            let modified = apply_branch_strictness(result, &config, Some(temp.path()));
5424
5425            // Decision stays Deny because detached_head_strictness=All blocks Low.
5426            assert_eq!(modified.decision, EvaluationDecision::Deny);
5427            let branch_context = modified
5428                .branch_context
5429                .expect("branch context should be populated");
5430            assert!(branch_context.branch_name.is_none());
5431            assert!(!branch_context.is_protected);
5432            assert!(!branch_context.is_relaxed);
5433            assert_eq!(branch_context.strictness, StrictnessLevel::All);
5434        }
5435
5436        #[test]
5437        fn detached_head_can_be_set_to_default_strictness() {
5438            // Opt-out: setting detached_head_strictness equal to
5439            // default_strictness restores the previous (loose) behavior.
5440            let temp = tempfile::tempdir().expect("tempdir");
5441            init_git_repo_detached(temp.path());
5442
5443            let mut config = Config::default();
5444            config.git_awareness.enabled = true;
5445            config.git_awareness.default_strictness = StrictnessLevel::Critical;
5446            config.git_awareness.detached_head_strictness = StrictnessLevel::Critical;
5447            config.git_awareness.warn_if_not_git = false;
5448
5449            let result = create_deny_result_with_severity(Severity::Low);
5450            let modified = apply_branch_strictness(result, &config, Some(temp.path()));
5451
5452            // Critical strictness lets Low through.
5453            assert_eq!(modified.decision, EvaluationDecision::Allow);
5454            let branch_context = modified
5455                .branch_context
5456                .expect("branch context should be populated");
5457            assert_eq!(branch_context.strictness, StrictnessLevel::Critical);
5458            assert!(branch_context.affected_decision);
5459        }
5460
5461        #[test]
5462        fn detached_head_strictness_defaults_to_all() {
5463            let cfg = Config::default();
5464            assert_eq!(
5465                cfg.git_awareness.detached_head_strictness,
5466                StrictnessLevel::All,
5467                "detached HEAD must default to the strictest level"
5468            );
5469        }
5470    }
5471
5472    mod heredoc_fail_open {
5473        use super::*;
5474
5475        fn heredoc_config(
5476            fallback_on_parse_error: bool,
5477            fallback_on_timeout: bool,
5478        ) -> crate::config::HeredocSettings {
5479            crate::config::HeredocSettings {
5480                enabled: true,
5481                fallback_on_parse_error,
5482                fallback_on_timeout,
5483                limits: crate::heredoc::ExtractionLimits::default(),
5484                allowed_languages: None,
5485                content_allowlist: None,
5486            }
5487        }
5488
5489        fn heredoc_config_with_limits(
5490            limits: crate::heredoc::ExtractionLimits,
5491        ) -> crate::config::HeredocSettings {
5492            crate::config::HeredocSettings {
5493                enabled: true,
5494                fallback_on_parse_error: true,
5495                fallback_on_timeout: true,
5496                limits,
5497                allowed_languages: None,
5498                content_allowlist: None,
5499            }
5500        }
5501
5502        fn eval_with_heredoc(
5503            command: &str,
5504            settings: &crate::config::HeredocSettings,
5505        ) -> EvaluationResult {
5506            let config = default_config();
5507            let enabled_packs = config.enabled_pack_ids();
5508            let ordered_packs = crate::packs::REGISTRY.expand_enabled_ordered(&enabled_packs);
5509            let enabled_keywords = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
5510            let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
5511            let compiled = default_compiled_overrides();
5512            let allowlists = default_allowlists();
5513
5514            evaluate_command_with_pack_order(
5515                command,
5516                enabled_keywords.as_slice(),
5517                ordered_packs.as_slice(),
5518                keyword_index.as_ref(),
5519                &compiled,
5520                &allowlists,
5521                settings,
5522            )
5523        }
5524
5525        #[test]
5526        fn unterminated_heredoc_allows_in_failopen_mode() {
5527            let settings = heredoc_config(true, true);
5528            let cmd = "python3 -c 'import shutil' << EOF\nsome content without closing";
5529            let result = eval_with_heredoc(cmd, &settings);
5530            assert!(
5531                result.is_allowed(),
5532                "unterminated heredoc should fail-open when fallback_on_parse_error=true"
5533            );
5534        }
5535
5536        #[test]
5537        fn exceeded_size_limit_allows_in_failopen_mode() {
5538            let limits = crate::heredoc::ExtractionLimits {
5539                max_body_bytes: 10,
5540                max_body_lines: 10_000,
5541                max_heredocs: 10,
5542                timeout_ms: 50,
5543            };
5544            let settings = heredoc_config_with_limits(limits);
5545            let cmd = "bash -c 'echo test' << EOF\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nEOF";
5546            let result = eval_with_heredoc(cmd, &settings);
5547            assert!(
5548                result.is_allowed(),
5549                "exceeded size limit should fail-open with default settings"
5550            );
5551        }
5552
5553        #[test]
5554        fn exceeded_line_limit_allows_in_failopen_mode() {
5555            let limits = crate::heredoc::ExtractionLimits {
5556                max_body_bytes: 1024 * 1024,
5557                max_body_lines: 1,
5558                max_heredocs: 10,
5559                timeout_ms: 50,
5560            };
5561            let settings = heredoc_config_with_limits(limits);
5562            let cmd = "bash -c 'echo test' << EOF\nline1\nline2\nline3\nEOF";
5563            let result = eval_with_heredoc(cmd, &settings);
5564            assert!(
5565                result.is_allowed(),
5566                "exceeded line limit should fail-open with default settings"
5567            );
5568        }
5569
5570        #[test]
5571        fn exceeded_heredoc_limit_allows_in_failopen_mode() {
5572            let limits = crate::heredoc::ExtractionLimits {
5573                max_body_bytes: 1024 * 1024,
5574                max_body_lines: 10_000,
5575                max_heredocs: 0,
5576                timeout_ms: 50,
5577            };
5578            let settings = heredoc_config_with_limits(limits);
5579            let cmd = "bash -c 'echo test' << EOF\ncontent\nEOF";
5580            let result = eval_with_heredoc(cmd, &settings);
5581            assert!(
5582                result.is_allowed(),
5583                "exceeded heredoc limit should fail-open with default settings"
5584            );
5585        }
5586
5587        #[test]
5588        fn binary_content_allows_in_failopen_mode() {
5589            let settings = heredoc_config(true, true);
5590            let cmd = "python3 -c '\x00\x01\x02\x03\x04\x05\x06\x07'";
5591            let result = eval_with_heredoc(cmd, &settings);
5592            assert!(
5593                result.is_allowed(),
5594                "binary content should fail-open with default settings"
5595            );
5596        }
5597
5598        #[test]
5599        fn strict_parse_error_denies_on_unterminated_heredoc() {
5600            let settings = heredoc_config(false, true);
5601            let cmd = "cat << EOF\ncontent without closing delimiter";
5602            let result = eval_with_heredoc(cmd, &settings);
5603            assert!(
5604                result.is_denied(),
5605                "unterminated heredoc should deny when fallback_on_parse_error=false, \
5606                 got: {result:?}"
5607            );
5608        }
5609
5610        #[test]
5611        fn strict_parse_error_denies_on_exceeded_size() {
5612            let mut settings = heredoc_config(false, true);
5613            settings.limits.max_body_bytes = 5;
5614            let cmd = "cat << EOF\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nEOF";
5615            let result = eval_with_heredoc(cmd, &settings);
5616            assert!(
5617                result.is_denied(),
5618                "exceeded size should deny when fallback_on_parse_error=false, \
5619                 got: {result:?}"
5620            );
5621        }
5622
5623        #[test]
5624        fn heredoc_disabled_skips_all_extraction() {
5625            let settings = crate::config::HeredocSettings {
5626                enabled: false,
5627                ..Default::default()
5628            };
5629            let cmd = "python3 -c 'import shutil; shutil.rmtree(\"/tmp\")'";
5630            let result = eval_with_heredoc(cmd, &settings);
5631            assert!(
5632                result.is_allowed(),
5633                "with heredoc disabled, inline scripts should not be analyzed"
5634            );
5635        }
5636
5637        #[test]
5638        fn safe_command_with_heredoc_trigger_still_allowed() {
5639            let settings = heredoc_config(true, true);
5640            let cmd = "python3 -c 'print(42)'";
5641            let result = eval_with_heredoc(cmd, &settings);
5642            assert!(
5643                result.is_allowed(),
5644                "safe heredoc content should be allowed"
5645            );
5646        }
5647    }
5648
5649    mod graduation_tests {
5650        use super::*;
5651        use crate::config::{GraduationMode, ResponseConfig, SeverityOverrides};
5652        use crate::packs::Severity;
5653
5654        fn enabled_config() -> ResponseConfig {
5655            ResponseConfig {
5656                enabled: true,
5657                ..ResponseConfig::default()
5658            }
5659        }
5660
5661        #[test]
5662        fn disabled_config_returns_none() {
5663            let config = ResponseConfig::default(); // enabled = false
5664            let result = determine_graduated_response(5, Severity::High, &config);
5665            assert!(result.is_none());
5666        }
5667
5668        #[test]
5669        fn disabled_mode_returns_none() {
5670            let mut config = enabled_config();
5671            config.mode = GraduationMode::Disabled;
5672            let result = determine_graduated_response(5, Severity::Medium, &config);
5673            assert!(result.is_none());
5674        }
5675
5676        #[test]
5677        fn warning_only_always_warns() {
5678            let mut config = enabled_config();
5679            config.mode = GraduationMode::WarningOnly;
5680            for count in [1, 5, 100] {
5681                let result =
5682                    determine_graduated_response(count, Severity::Medium, &config).unwrap();
5683                assert!(
5684                    matches!(result, GraduatedResponse::Warning { .. }),
5685                    "WarningOnly should always warn, got {:?}",
5686                    result
5687                );
5688            }
5689        }
5690
5691        #[test]
5692        fn paranoid_always_hard_blocks() {
5693            let mut config = enabled_config();
5694            config.mode = GraduationMode::Paranoid;
5695            let result = determine_graduated_response(1, Severity::Medium, &config).unwrap();
5696            assert!(matches!(result, GraduatedResponse::HardBlock { .. }));
5697        }
5698
5699        #[test]
5700        fn standard_mode_progression() {
5701            let config = enabled_config();
5702            // session_warning_count=1, session_soft_block=2
5703
5704            // count=1 -> Warning
5705            let r = determine_graduated_response(1, Severity::High, &config).unwrap();
5706            assert!(matches!(r, GraduatedResponse::Warning { occurrence: 1 }));
5707
5708            // count=2 -> SoftBlock
5709            let r = determine_graduated_response(2, Severity::High, &config).unwrap();
5710            assert!(matches!(r, GraduatedResponse::SoftBlock { occurrence: 2 }));
5711
5712            // count=5 -> SoftBlock (still)
5713            let r = determine_graduated_response(5, Severity::High, &config).unwrap();
5714            assert!(matches!(r, GraduatedResponse::SoftBlock { occurrence: 5 }));
5715        }
5716
5717        #[test]
5718        fn strict_mode_immediate_soft_block() {
5719            let mut config = enabled_config();
5720            config.mode = GraduationMode::Strict;
5721            // count=1 -> SoftBlock (immediate)
5722            let r = determine_graduated_response(1, Severity::Medium, &config).unwrap();
5723            assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
5724            // count=session_soft_block -> HardBlock
5725            let r =
5726                determine_graduated_response(config.session_soft_block, Severity::Medium, &config)
5727                    .unwrap();
5728            assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
5729        }
5730
5731        #[test]
5732        fn lenient_mode_doubles_thresholds() {
5733            let mut config = enabled_config();
5734            config.mode = GraduationMode::Lenient;
5735            // Default: session_warning_count=1, session_soft_block=2
5736            // Lenient doubles: warn at 2, soft_block at 4
5737
5738            // count=1 -> None (below doubled warning threshold)
5739            let r = determine_graduated_response(1, Severity::Medium, &config);
5740            assert!(r.is_none());
5741
5742            // count=2 -> Warning
5743            let r = determine_graduated_response(2, Severity::Medium, &config).unwrap();
5744            assert!(matches!(r, GraduatedResponse::Warning { .. }));
5745
5746            // count=4 -> SoftBlock
5747            let r = determine_graduated_response(4, Severity::Medium, &config).unwrap();
5748            assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
5749        }
5750
5751        #[test]
5752        fn severity_defaults_for_critical_and_low() {
5753            let config = enabled_config();
5754            // Critical defaults to Paranoid -> HardBlock
5755            let r = determine_graduated_response(1, Severity::Critical, &config).unwrap();
5756            assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
5757            // Low defaults to WarningOnly -> Warning
5758            let r = determine_graduated_response(1, Severity::Low, &config).unwrap();
5759            assert!(matches!(r, GraduatedResponse::Warning { .. }));
5760        }
5761
5762        #[test]
5763        fn severity_override_takes_precedence() {
5764            let mut config = enabled_config();
5765            config.severity_overrides = SeverityOverrides {
5766                critical: Some(GraduationMode::WarningOnly),
5767                high: None,
5768                medium: None,
5769                low: Some(GraduationMode::Paranoid),
5770            };
5771            // Critical overridden to WarningOnly
5772            let r = determine_graduated_response(1, Severity::Critical, &config).unwrap();
5773            assert!(matches!(r, GraduatedResponse::Warning { .. }));
5774            // Low overridden to Paranoid
5775            let r = determine_graduated_response(1, Severity::Low, &config).unwrap();
5776            assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
5777        }
5778
5779        #[test]
5780        fn apply_graduation_on_denied_result() {
5781            let mut config = enabled_config();
5782            config.session_warning_count = 1;
5783            let mut result = EvaluationResult::denied_by_pack_pattern(
5784                "core.git",
5785                "reset-hard",
5786                "Destroys uncommitted changes",
5787                None,
5788                Severity::High,
5789                &[],
5790            );
5791            result.session_occurrence = Some(crate::session::OccurrenceSnapshot {
5792                command_hash: "abc".to_string(),
5793                session_count: 1,
5794                distinct_commands: 1,
5795                total_occurrences: 1,
5796            });
5797            result.apply_graduation(&config);
5798            assert!(result.graduated_response.is_some());
5799            assert!(matches!(
5800                result.graduated_response,
5801                Some(GraduatedResponse::Warning { occurrence: 1 })
5802            ));
5803        }
5804
5805        #[test]
5806        fn apply_graduation_skipped_when_disabled() {
5807            let config = ResponseConfig::default(); // enabled=false
5808            let mut result = EvaluationResult::denied_by_pack("test", "reason", None);
5809            result.session_occurrence = Some(crate::session::OccurrenceSnapshot {
5810                command_hash: "abc".to_string(),
5811                session_count: 5,
5812                distinct_commands: 1,
5813                total_occurrences: 5,
5814            });
5815            result.apply_graduation(&config);
5816            assert!(result.graduated_response.is_none());
5817        }
5818
5819        #[test]
5820        fn apply_graduation_no_occurrence_data() {
5821            let config = enabled_config();
5822            let mut result = EvaluationResult::denied_by_pack("test", "reason", None);
5823            // No session_occurrence set
5824            result.apply_graduation(&config);
5825            assert!(result.graduated_response.is_none());
5826        }
5827
5828        #[test]
5829        fn graduated_response_blocks() {
5830            assert!(!GraduatedResponse::Warning { occurrence: 1 }.blocks());
5831            assert!(GraduatedResponse::SoftBlock { occurrence: 2 }.blocks());
5832            assert!(
5833                GraduatedResponse::HardBlock {
5834                    total_occurrences: 5
5835                }
5836                .blocks()
5837            );
5838        }
5839
5840        #[test]
5841        fn graduated_response_is_hard_block() {
5842            assert!(!GraduatedResponse::Warning { occurrence: 1 }.is_hard_block());
5843            assert!(!GraduatedResponse::SoftBlock { occurrence: 2 }.is_hard_block());
5844            assert!(
5845                GraduatedResponse::HardBlock {
5846                    total_occurrences: 5
5847                }
5848                .is_hard_block()
5849            );
5850        }
5851
5852        #[test]
5853        fn graduated_response_labels() {
5854            assert_eq!(
5855                GraduatedResponse::Warning { occurrence: 3 }.label(),
5856                "warning (occurrence #3)"
5857            );
5858            assert_eq!(
5859                GraduatedResponse::SoftBlock { occurrence: 2 }.label(),
5860                "soft block (occurrence #2)"
5861            );
5862            assert_eq!(
5863                GraduatedResponse::HardBlock {
5864                    total_occurrences: 5
5865                }
5866                .label(),
5867                "hard block (5 total occurrences)"
5868            );
5869        }
5870
5871        #[test]
5872        fn bypass_method_labels() {
5873            assert_eq!(BypassMethod::Force.label(), "force");
5874            assert_eq!(BypassMethod::AllowOnce.label(), "allow_once");
5875        }
5876
5877        #[test]
5878        fn decision_mode_strings() {
5879            assert_eq!(
5880                GraduatedResponse::Warning { occurrence: 1 }.decision_mode(),
5881                "warning"
5882            );
5883            assert_eq!(
5884                GraduatedResponse::SoftBlock { occurrence: 1 }.decision_mode(),
5885                "soft_block"
5886            );
5887            assert_eq!(
5888                GraduatedResponse::HardBlock {
5889                    total_occurrences: 1
5890                }
5891                .decision_mode(),
5892                "hard_block"
5893            );
5894        }
5895
5896        // ====================================================================
5897        // History-backed graduation (git_safety_guard-n9j1)
5898        // ====================================================================
5899
5900        #[test]
5901        fn standard_mode_history_count_at_soft_threshold_escalates_to_softblock() {
5902            // session_count=1 alone in Standard would only Warn. With
5903            // history_count >= history_soft_block (default 3), the response
5904            // must escalate to SoftBlock.
5905            let config = enabled_config();
5906            let r = determine_graduated_response_with_history(
5907                1,
5908                Some(config.history_soft_block),
5909                Severity::High,
5910                &config,
5911            )
5912            .unwrap();
5913            assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
5914        }
5915
5916        #[test]
5917        fn standard_mode_history_count_at_hard_threshold_escalates_to_hardblock() {
5918            let config = enabled_config();
5919            let r = determine_graduated_response_with_history(
5920                1,
5921                Some(config.history_hard_block),
5922                Severity::High,
5923                &config,
5924            )
5925            .unwrap();
5926            assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
5927        }
5928
5929        #[test]
5930        fn standard_mode_history_below_threshold_keeps_session_response() {
5931            let config = enabled_config();
5932            // history_count=1, below soft_block=3; session_count=1 → Warning.
5933            let r = determine_graduated_response_with_history(1, Some(1), Severity::High, &config)
5934                .unwrap();
5935            assert!(matches!(r, GraduatedResponse::Warning { occurrence: 1 }));
5936        }
5937
5938        #[test]
5939        fn paranoid_mode_ignores_history_count() {
5940            let mut config = enabled_config();
5941            config.mode = GraduationMode::Paranoid;
5942            // History should not change Paranoid's HardBlock behavior.
5943            let r =
5944                determine_graduated_response_with_history(1, Some(99), Severity::Medium, &config)
5945                    .unwrap();
5946            assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
5947        }
5948
5949        #[test]
5950        fn lenient_mode_history_can_escalate_when_session_says_none() {
5951            let mut config = enabled_config();
5952            config.mode = GraduationMode::Lenient;
5953            // session_count=1 in Lenient (doubled warn=2) → None.
5954            // history_count >= soft_block escalates to SoftBlock.
5955            let r = determine_graduated_response_with_history(
5956                1,
5957                Some(config.history_soft_block),
5958                Severity::Medium,
5959                &config,
5960            )
5961            .unwrap();
5962            assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
5963        }
5964
5965        #[test]
5966        fn history_none_matches_legacy_signature() {
5967            // The new entrypoint with history_count=None must agree exactly
5968            // with the legacy session-only entrypoint.
5969            let config = enabled_config();
5970            for sc in [0, 1, 2, 5, 10] {
5971                for sev in [
5972                    Severity::Critical,
5973                    Severity::High,
5974                    Severity::Medium,
5975                    Severity::Low,
5976                ] {
5977                    let legacy = determine_graduated_response(sc, sev, &config);
5978                    let new_none =
5979                        determine_graduated_response_with_history(sc, None, sev, &config);
5980                    assert_eq!(legacy, new_none, "must match for sc={sc} sev={sev:?}");
5981                }
5982            }
5983        }
5984
5985        #[test]
5986        fn parse_history_window_recognized_units() {
5987            use crate::config::ResponseConfig;
5988            assert_eq!(
5989                ResponseConfig::parse_history_window("24h"),
5990                Some(chrono::Duration::hours(24))
5991            );
5992            assert_eq!(
5993                ResponseConfig::parse_history_window("7d"),
5994                Some(chrono::Duration::days(7))
5995            );
5996            assert_eq!(
5997                ResponseConfig::parse_history_window("30m"),
5998                Some(chrono::Duration::minutes(30))
5999            );
6000            assert_eq!(
6001                ResponseConfig::parse_history_window("90s"),
6002                Some(chrono::Duration::seconds(90))
6003            );
6004            assert_eq!(ResponseConfig::parse_history_window(""), None);
6005            assert_eq!(ResponseConfig::parse_history_window("24x"), None);
6006        }
6007
6008        #[test]
6009        fn parse_history_window_rejects_negative_and_overflow() {
6010            use crate::config::ResponseConfig;
6011            // Negative values would wrap (Utc::now() - (-window) = future cutoff).
6012            assert_eq!(ResponseConfig::parse_history_window("-1h"), None);
6013            assert_eq!(ResponseConfig::parse_history_window("-100d"), None);
6014            // Values beyond the 100-year sane cap are rejected so we never
6015            // hit chrono's panic-on-overflow path.
6016            assert_eq!(ResponseConfig::parse_history_window("99999999999d"), None);
6017            assert_eq!(
6018                ResponseConfig::parse_history_window("9999999999999999999s"),
6019                None
6020            );
6021            // Right at the cap is accepted.
6022            assert_eq!(
6023                ResponseConfig::parse_history_window("36500d"),
6024                Some(chrono::Duration::days(36500))
6025            );
6026        }
6027
6028        #[test]
6029        fn parse_history_window_handles_multibyte_trailing_char() {
6030            use crate::config::ResponseConfig;
6031            // Regression: previous `split_at(len-1)` would panic on a
6032            // multi-byte trailing char. Char iteration is safe.
6033            assert_eq!(ResponseConfig::parse_history_window("24é"), None);
6034        }
6035    }
6036}