1use 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
66const 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
78const MAX_PREVIEW_CHARS: usize = 80;
80
81fn extract_match_preview(command: &str, span: &MatchSpan) -> String {
89 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 let safe_start = if command.is_char_boundary(start) {
101 start
102 } else {
103 (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 (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 let matched = &command[safe_start..safe_end];
124
125 truncate_preview(matched, MAX_PREVIEW_CHARS)
127}
128
129fn 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 let truncate_at = max_chars.saturating_sub(3);
139 let truncated: String = text.chars().take(truncate_at).collect();
140 format!("{truncated}...")
141 }
142}
143
144pub const DEFAULT_WINDOW_WIDTH: usize = 120;
150
151#[derive(Debug, Clone, PartialEq, Eq)]
153pub struct WindowedCommand {
154 pub display: String,
156 pub adjusted_span: Option<WindowedSpan>,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub struct WindowedSpan {
164 pub start: usize,
166 pub end: usize,
168}
169
170fn 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#[must_use]
222pub fn window_command(command: &str, span: &MatchSpan, max_width: usize) -> WindowedCommand {
223 let char_count = command.chars().count();
224
225 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 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 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 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 let ellipsis_len = 3;
255 let available_width = max_width.saturating_sub(ellipsis_len * 2);
256
257 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 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 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 let needs_left_ellipsis = window_char_start > 0;
283 let needs_right_ellipsis = window_char_end < char_count;
284
285 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 let windowed: String = command
296 .chars()
297 .skip(window_char_start)
298 .take(window_char_end - window_char_start)
299 .collect();
300
301 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
320fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
391pub enum EvaluationDecision {
392 Allow,
394 Deny,
396}
397
398#[derive(Debug, Clone, Copy, PartialEq, Eq)]
400pub struct MatchSpan {
401 pub start: usize,
403 pub end: usize,
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
409pub struct PatternMatch {
410 pub pack_id: Option<String>,
412 pub pattern_name: Option<String>,
414 pub severity: Option<crate::packs::Severity>,
416 pub reason: String,
418 pub source: MatchSource,
420 pub matched_span: Option<MatchSpan>,
422 pub matched_text_preview: Option<String>,
424 pub explanation: Option<String>,
428 pub suggestions: &'static [PatternSuggestion],
430}
431
432#[derive(Debug, Clone, PartialEq, Eq)]
434pub struct AllowlistOverride {
435 pub layer: AllowlistLayer,
437 pub reason: String,
439 pub matched: PatternMatch,
441}
442
443#[derive(Debug, Clone, Copy, PartialEq, Eq)]
445pub enum MatchSource {
446 ConfigOverride,
448 LegacyPattern,
450 Pack,
452 HeredocAst,
454}
455
456#[derive(Debug, Clone, PartialEq, Eq)]
460pub struct BranchContext {
461 pub branch_name: Option<String>,
463 pub is_protected: bool,
465 pub is_relaxed: bool,
467 pub strictness: crate::config::StrictnessLevel,
469 pub affected_decision: bool,
473}
474
475#[derive(Debug, Clone)]
477pub struct EvaluationResult {
478 pub decision: EvaluationDecision,
480 pub pattern_info: Option<PatternMatch>,
482 pub allowlist_override: Option<AllowlistOverride>,
484 pub effective_mode: Option<crate::packs::DecisionMode>,
490 pub skipped_due_to_budget: bool,
492 pub branch_context: Option<BranchContext>,
494 pub session_occurrence: Option<crate::session::OccurrenceSnapshot>,
497 pub graduated_response: Option<GraduatedResponse>,
499 pub bypass_method: Option<BypassMethod>,
501}
502
503impl EvaluationResult {
504 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[inline]
779 #[must_use]
780 pub fn is_allowed(&self) -> bool {
781 self.decision == EvaluationDecision::Allow
782 }
783
784 #[inline]
786 #[must_use]
787 pub fn is_denied(&self) -> bool {
788 self.decision == EvaluationDecision::Deny
789 }
790
791 #[must_use]
793 pub fn reason(&self) -> Option<&str> {
794 self.pattern_info.as_ref().map(|p| p.reason.as_str())
795 }
796
797 #[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 #[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 pub fn apply_graduation(&mut self, config: &crate::config::ResponseConfig) {
818 self.apply_graduation_with_history_count(None, config);
819 }
820
821 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 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 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#[derive(Debug, Clone, PartialEq, Eq)]
892pub enum GraduatedResponse {
893 Warning { occurrence: u32 },
895 SoftBlock { occurrence: u32 },
897 HardBlock { total_occurrences: u32 },
899}
900
901impl GraduatedResponse {
902 #[must_use]
904 pub const fn blocks(&self) -> bool {
905 matches!(self, Self::SoftBlock { .. } | Self::HardBlock { .. })
906 }
907
908 #[must_use]
910 pub const fn is_hard_block(&self) -> bool {
911 matches!(self, Self::HardBlock { .. })
912 }
913
914 #[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
939pub enum BypassMethod {
940 Force,
942 AllowOnce,
944}
945
946impl BypassMethod {
947 #[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#[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#[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 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 Some(GraduatedResponse::HardBlock {
1050 total_occurrences: session_count,
1051 })
1052 }
1053 GraduationMode::Strict => {
1054 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 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 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#[derive(Debug, Clone)]
1142pub struct DetailedEvaluationResult {
1143 pub result: EvaluationResult,
1145 pub keywords_checked: Vec<String>,
1148 pub evaluation_time_us: u64,
1150 pub confidence: Option<ConfidenceResult>,
1152 pub normalized_command: Option<String>,
1155 pub quick_rejected: bool,
1157}
1158
1159impl DetailedEvaluationResult {
1160 #[inline]
1162 #[must_use]
1163 pub fn is_allowed(&self) -> bool {
1164 self.result.is_allowed()
1165 }
1166
1167 #[inline]
1169 #[must_use]
1170 pub fn is_denied(&self) -> bool {
1171 self.result.is_denied()
1172 }
1173
1174 #[inline]
1176 #[must_use]
1177 pub fn into_result(self) -> EvaluationResult {
1178 self.result
1179 }
1180
1181 #[inline]
1183 #[must_use]
1184 pub const fn result(&self) -> &EvaluationResult {
1185 &self.result
1186 }
1187}
1188
1189#[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#[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 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 let quick_rejected = pack_aware_quick_reject(command, &enabled_keywords);
1266
1267 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 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 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#[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#[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#[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#[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#[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#[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 if deadline_exceeded(deadline) {
1579 return EvaluationResult::allowed_due_to_budget();
1580 }
1581
1582 if command.is_empty() {
1584 return EvaluationResult::allowed();
1585 }
1586
1587 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 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 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 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 let sanitized = precomputed_sanitized.unwrap_or_else(|| sanitize_for_pattern_matching(command));
1685 let command_for_match = sanitized.as_ref();
1686
1687 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 if allow_once_match(command, allow_once_audit).is_some() {
1712 return EvaluationResult::allowed();
1713 }
1714
1715 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 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 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 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 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 if let Some(entry) = REGISTRY.get_entry(pack_id) {
1837 return Some((pack_id, entry.get_pack()));
1838 }
1839 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 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 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 match rm_parse.as_ref() {
1890 Some(crate::packs::core::filesystem::RmParseDecision::Allow)
1891 if !has_pre_rm_propagation_match =>
1892 {
1893 continue; }
1895 Some(crate::packs::core::filesystem::RmParseDecision::Allow) => {
1896 }
1901 Some(crate::packs::core::filesystem::RmParseDecision::NoMatch) | None => {
1902 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 &[], 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 &[], );
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; }
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 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 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 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 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 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#[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 if command.is_empty() {
2465 return EvaluationResult::allowed();
2466 }
2467
2468 let allow_once = allow_once_match(command, None);
2470
2471 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 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 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 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 let sanitized = precomputed_sanitized.unwrap_or_else(|| sanitize_for_pattern_matching(command));
2548 let command_for_match = sanitized.as_ref();
2549
2550 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 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 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 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, );
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#[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 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 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 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 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 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 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 continue;
2763 }
2764 }
2765
2766 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; }
2781
2782 if content.language == crate::heredoc::ScriptLanguage::Bash {
2786 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 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; 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 } }
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 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", r"\bgit\s+reset\s+--hard\b",
2971 ])
2972 .expect("fallback patterns must compile")
2973 });
2974
2975 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 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 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
3059pub trait LegacySafePattern {
3061 fn is_match(&self, cmd: &str) -> bool;
3063}
3064
3065pub trait LegacyDestructivePattern {
3067 fn is_match(&self, cmd: &str) -> bool;
3069 fn find_span(&self, cmd: &str) -> Option<MatchSpan> {
3071 let _ = cmd;
3072 None
3073 }
3074 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#[derive(Debug, Clone)]
3106pub struct ConfidenceResult {
3107 pub mode: crate::packs::DecisionMode,
3109 pub score: Option<crate::confidence::ConfidenceScore>,
3111 pub downgraded: bool,
3113}
3114
3115#[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 !config.enabled {
3141 return ConfidenceResult {
3142 mode: current_mode,
3143 score: None,
3144 downgraded: false,
3145 };
3146 }
3147
3148 if current_mode != crate::packs::DecisionMode::Deny {
3150 return ConfidenceResult {
3151 mode: current_mode,
3152 score: None,
3153 downgraded: false,
3154 };
3155 }
3156
3157 let Some(info) = &result.pattern_info else {
3159 return ConfidenceResult {
3160 mode: current_mode,
3161 score: None,
3162 downgraded: false,
3163 };
3164 };
3165
3166 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 let Some(span) = &info.matched_span else {
3181 return ConfidenceResult {
3183 mode: current_mode,
3184 score: None,
3185 downgraded: false,
3186 };
3187 };
3188
3189 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 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#[must_use]
3228pub fn apply_branch_strictness(
3229 mut result: EvaluationResult,
3230 config: &Config,
3231 project_path: Option<&Path>,
3232) -> EvaluationResult {
3233 let git_awareness = &config.git_awareness;
3235 if !git_awareness.enabled {
3236 return result;
3237 }
3238
3239 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 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 tracing::debug!(
3253 "Not in git repository, using default strictness (git_awareness enabled but no repo detected)"
3254 );
3255 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 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 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 let mut affected_decision = false;
3284
3285 if result.decision == EvaluationDecision::Deny {
3287 if let Some(ref pattern_info) = result.pattern_info {
3288 if let Some(severity) = pattern_info.severity {
3289 if !strictness.should_block(severity) {
3291 result.decision = EvaluationDecision::Allow;
3293 affected_decision = true;
3294 }
3295 }
3296 }
3297 }
3298
3299 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 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 #[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 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 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 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 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 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 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 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 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 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 #[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 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 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 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 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") );
4184 }
4185
4186 #[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 "ls -la",
4204 "cargo build --release",
4205 "npm install",
4206 "echo hello",
4207 "cat /etc/passwd",
4208 "",
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]
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 #[test]
4323 fn truncate_preview_handles_utf8_safely() {
4324 let short = "hello";
4326 assert_eq!(super::truncate_preview(short, 10), "hello");
4327
4328 let exact = "hello";
4330 assert_eq!(super::truncate_preview(exact, 5), "hello");
4331
4332 let long = "hello world";
4334 assert_eq!(super::truncate_preview(long, 8), "hello...");
4335
4336 let japanese = "こんにちは世界"; let truncated = super::truncate_preview(japanese, 5);
4339 assert!(truncated.ends_with("..."));
4340 assert_eq!(truncated, "こん...");
4342
4343 let emoji = "🔥🔥🔥🔥🔥"; let truncated_emoji = super::truncate_preview(emoji, 3);
4346 assert_eq!(truncated_emoji, "..."); }
4348
4349 #[test]
4350 fn extract_match_preview_bounds_check() {
4351 let cmd = "rm -rf /important";
4352
4353 let span = super::MatchSpan { start: 0, end: 2 };
4355 assert_eq!(super::extract_match_preview(cmd, &span), "rm");
4356
4357 let span_end = super::MatchSpan { start: 7, end: 17 };
4359 assert_eq!(super::extract_match_preview(cmd, &span_end), "/important");
4360
4361 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 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 let cmd = "日本語"; let valid_span = super::MatchSpan { start: 0, end: 3 };
4386 assert_eq!(super::extract_match_preview(cmd, &valid_span), "日");
4387
4388 let invalid_start = super::MatchSpan { start: 1, end: 6 };
4391 assert_eq!(super::extract_match_preview(cmd, &invalid_start), "本");
4392
4393 let invalid_end = super::MatchSpan { start: 0, end: 4 };
4396 assert_eq!(super::extract_match_preview(cmd, &invalid_end), "日");
4397
4398 let both_invalid = super::MatchSpan { start: 1, end: 4 };
4400 assert_eq!(super::extract_match_preview(cmd, &both_invalid), "");
4402
4403 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 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 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 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 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 #[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 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 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 #[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 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 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 #[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, );
4604
4605 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 #[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 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 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]
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 #[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 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 let adversarial = format!("rm {}", "a ".repeat(30));
4703
4704 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 #[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 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 #[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 #[test]
4819 fn medium_severity_patterns_are_evaluated() {
4820 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 let result = evaluate_command(
4829 "docker image prune",
4830 &config,
4831 &["docker"],
4832 &compiled,
4833 &allowlists,
4834 );
4835
4836 assert!(
4838 result.is_denied(),
4839 "Medium severity pattern should be evaluated and return Deny"
4840 );
4841
4842 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 let config = default_config();
4860 let compiled = config.overrides.compile();
4861 let allowlists = default_allowlists();
4862
4863 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 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 let config = default_config();
4903 let compiled = config.overrides.compile();
4904 let allowlists = default_allowlists();
4905
4906 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 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 let policy = crate::config::PolicyConfig::default();
4939
4940 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 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 #[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 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 assert!(result.display.starts_with("..."));
4998 assert!(result.display.ends_with("..."));
4999 assert!(result.display.contains("git reset --hard"));
5000
5001 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 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 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 let cmd = "echo 🎉🎊🎈 && rm -rf / && echo done";
5052 let span = MatchSpan { start: 21, end: 29 }; 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 }; let result = window_command(cmd, &span, 80);
5071
5072 assert_eq!(result.display, "short");
5074 assert!(result.adjusted_span.is_none());
5075 }
5076
5077 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 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 assert_eq!(modified.decision, EvaluationDecision::Deny);
5160 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 assert_eq!(
5255 config.strictness_for_branch(Some("main")),
5256 StrictnessLevel::All
5257 );
5258 assert_eq!(
5260 config.strictness_for_branch(Some("feature/test")),
5261 StrictnessLevel::Critical
5262 );
5263 assert_eq!(
5265 config.strictness_for_branch(Some("develop")),
5266 StrictnessLevel::High
5267 );
5268 assert_eq!(config.strictness_for_branch(None), StrictnessLevel::High);
5270 }
5271
5272 #[test]
5273 fn git_awareness_not_in_repo_uses_default_strictness() {
5274 let mut config = Config::default();
5277 config.git_awareness.enabled = true;
5278 config.git_awareness.warn_if_not_git = false; 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 let temp_dir = std::env::temp_dir();
5305 let unique_dir = temp_dir.join(format!("dcg_test_{}", std::process::id()));
5307 let _ = std::fs::create_dir_all(&unique_dir);
5308
5309 let modified_result =
5311 apply_branch_strictness(result.clone(), &config, Some(unique_dir.as_path()));
5312
5313 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 let _ = std::fs::remove_dir(&unique_dir);
5322 }
5323
5324 #[test]
5325 fn git_awareness_warn_if_not_git_config() {
5326 let mut config = Config::default();
5328
5329 assert!(
5331 !config.git_awareness.warn_if_not_git,
5332 "warn_if_not_git should default to false"
5333 );
5334
5335 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 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 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 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 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 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(); 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 let r = determine_graduated_response(1, Severity::High, &config).unwrap();
5706 assert!(matches!(r, GraduatedResponse::Warning { occurrence: 1 }));
5707
5708 let r = determine_graduated_response(2, Severity::High, &config).unwrap();
5710 assert!(matches!(r, GraduatedResponse::SoftBlock { occurrence: 2 }));
5711
5712 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 let r = determine_graduated_response(1, Severity::Medium, &config).unwrap();
5723 assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
5724 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 let r = determine_graduated_response(1, Severity::Medium, &config);
5740 assert!(r.is_none());
5741
5742 let r = determine_graduated_response(2, Severity::Medium, &config).unwrap();
5744 assert!(matches!(r, GraduatedResponse::Warning { .. }));
5745
5746 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 let r = determine_graduated_response(1, Severity::Critical, &config).unwrap();
5756 assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
5757 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 let r = determine_graduated_response(1, Severity::Critical, &config).unwrap();
5773 assert!(matches!(r, GraduatedResponse::Warning { .. }));
5774 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(); 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 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 #[test]
5901 fn standard_mode_history_count_at_soft_threshold_escalates_to_softblock() {
5902 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 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 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 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 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 assert_eq!(ResponseConfig::parse_history_window("-1h"), None);
6013 assert_eq!(ResponseConfig::parse_history_window("-100d"), None);
6014 assert_eq!(ResponseConfig::parse_history_window("99999999999d"), None);
6017 assert_eq!(
6018 ResponseConfig::parse_history_window("9999999999999999999s"),
6019 None
6020 );
6021 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 assert_eq!(ResponseConfig::parse_history_window("24é"), None);
6034 }
6035 }
6036}