use crate::allowlist::{AllowlistLayer, LayeredAllowlist};
use crate::ast_matcher::DEFAULT_MATCHER;
use crate::config::Config;
use crate::context::sanitize_for_pattern_matching;
use crate::heredoc::{
ExtractionResult, SkipReason, TriggerResult, check_triggers, extract_content,
};
use crate::normalize::{PATH_NORMALIZER, QUOTED_PATH_NORMALIZER, strip_wrapper_prefixes};
use crate::packs::{
PatternSuggestion, REGISTRY, pack_aware_quick_reject, pack_aware_quick_reject_with_normalized,
};
use crate::pending_exceptions::AllowOnceStore;
use crate::perf::Deadline;
use chrono::Utc;
use regex::RegexSet;
use std::borrow::Cow;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
const fn ast_severity_to_pack_severity(s: crate::ast_matcher::Severity) -> crate::packs::Severity {
match s {
crate::ast_matcher::Severity::Critical => crate::packs::Severity::Critical,
crate::ast_matcher::Severity::High => crate::packs::Severity::High,
crate::ast_matcher::Severity::Medium => crate::packs::Severity::Medium,
crate::ast_matcher::Severity::Low => crate::packs::Severity::Low,
}
}
const MAX_PREVIEW_CHARS: usize = 80;
fn extract_match_preview(command: &str, span: &MatchSpan) -> String {
let start = span.start.min(command.len());
let end = span.end.min(command.len());
if start >= end {
return String::new();
}
let safe_start = if command.is_char_boundary(start) {
start
} else {
(start + 1..=command.len())
.find(|&i| command.is_char_boundary(i))
.unwrap_or(command.len())
};
let safe_end = if command.is_char_boundary(end) {
end
} else {
(0..end)
.rfind(|&i| command.is_char_boundary(i))
.unwrap_or(0)
};
if safe_start >= safe_end {
return String::new();
}
let matched = &command[safe_start..safe_end];
truncate_preview(matched, MAX_PREVIEW_CHARS)
}
fn truncate_preview(text: &str, max_chars: usize) -> String {
let char_count = text.chars().count();
if char_count <= max_chars {
text.to_string()
} else {
let truncate_at = max_chars.saturating_sub(3);
let truncated: String = text.chars().take(truncate_at).collect();
format!("{truncated}...")
}
}
pub const DEFAULT_WINDOW_WIDTH: usize = 120;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WindowedCommand {
pub display: String,
pub adjusted_span: Option<WindowedSpan>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct WindowedSpan {
pub start: usize,
pub end: usize,
}
fn snap_to_char_boundary(s: &str, offset: usize, prefer_forward: bool) -> usize {
if offset >= s.len() {
return s.len();
}
if s.is_char_boundary(offset) {
return offset;
}
if prefer_forward {
(offset + 1..=s.len())
.find(|&i| s.is_char_boundary(i))
.unwrap_or(s.len())
} else {
(0..offset).rfind(|&i| s.is_char_boundary(i)).unwrap_or(0)
}
}
#[must_use]
pub fn window_command(command: &str, span: &MatchSpan, max_width: usize) -> WindowedCommand {
let char_count = command.chars().count();
if char_count <= max_width {
let adjusted_span = byte_span_to_char_span(command, span);
return WindowedCommand {
display: command.to_string(),
adjusted_span,
};
}
let safe_start = snap_to_char_boundary(command, span.start, true);
let safe_end = snap_to_char_boundary(command, span.end, false);
if safe_start >= safe_end || safe_start >= command.len() {
let truncated: String = command.chars().take(max_width.saturating_sub(3)).collect();
return WindowedCommand {
display: format!("{truncated}..."),
adjusted_span: None,
};
}
let match_char_start = command[..safe_start].chars().count();
let match_char_end = command[..safe_end].chars().count();
let match_char_len = match_char_end.saturating_sub(match_char_start);
let ellipsis_len = 3;
let available_width = max_width.saturating_sub(ellipsis_len * 2);
if match_char_len >= available_width {
let visible_match: String = command[safe_start..safe_end]
.chars()
.take(available_width)
.collect();
return WindowedCommand {
display: format!("...{visible_match}..."),
adjusted_span: Some(WindowedSpan {
start: ellipsis_len,
end: ellipsis_len + visible_match.chars().count(),
}),
};
}
let context_budget = available_width.saturating_sub(match_char_len);
let left_context = context_budget / 2;
let right_context = context_budget - left_context;
let window_char_start = match_char_start.saturating_sub(left_context);
let window_char_end = (match_char_end + right_context).min(char_count);
let needs_left_ellipsis = window_char_start > 0;
let needs_right_ellipsis = window_char_end < char_count;
let mut result = String::new();
let adjusted_start = if needs_left_ellipsis {
result.push_str("...");
ellipsis_len
} else {
0
};
let windowed: String = command
.chars()
.skip(window_char_start)
.take(window_char_end - window_char_start)
.collect();
let span_start_in_window = match_char_start - window_char_start + adjusted_start;
let span_end_in_window = span_start_in_window + match_char_len;
result.push_str(&windowed);
if needs_right_ellipsis {
result.push_str("...");
}
WindowedCommand {
display: result,
adjusted_span: Some(WindowedSpan {
start: span_start_in_window,
end: span_end_in_window,
}),
}
}
fn byte_span_to_char_span(command: &str, span: &MatchSpan) -> Option<WindowedSpan> {
let safe_start = snap_to_char_boundary(command, span.start, true);
let safe_end = snap_to_char_boundary(command, span.end, false);
if safe_start >= safe_end || safe_start >= command.len() {
return None;
}
let char_start = command[..safe_start].chars().count();
let char_end = command[..safe_end].chars().count();
Some(WindowedSpan {
start: char_start,
end: char_end,
})
}
fn compute_normalized_offset(command_for_match: &str, normalized: &str) -> Option<usize> {
if normalized == command_for_match {
return Some(0);
}
if let Some(pos) = command_for_match.find(normalized) {
return Some(pos);
}
let stripped = strip_wrapper_prefixes(command_for_match);
let stripped_cmd = stripped.normalized.as_ref();
let base_offset = command_for_match.find(stripped_cmd)?;
if stripped_cmd == normalized {
return Some(base_offset);
}
if let Some(pos) = stripped_cmd.find(normalized) {
return Some(base_offset + pos);
}
if let Ok(Some(caps)) = QUOTED_PATH_NORMALIZER.captures(stripped_cmd) {
if let Some(m) = caps.get(1) {
return Some(base_offset + m.start());
}
}
if let Ok(Some(caps)) = PATH_NORMALIZER.captures(stripped_cmd) {
if let Some(m) = caps.get(1) {
return Some(base_offset + m.start());
}
}
None
}
fn map_span_with_offset(
span: MatchSpan,
offset: Option<usize>,
original_len: usize,
) -> Option<MatchSpan> {
let offset = offset?;
let start = span.start.saturating_add(offset);
let end = span.end.saturating_add(offset);
if start <= end && end <= original_len {
Some(MatchSpan { start, end })
} else {
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EvaluationDecision {
Allow,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct MatchSpan {
pub start: usize,
pub end: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PatternMatch {
pub pack_id: Option<String>,
pub pattern_name: Option<String>,
pub severity: Option<crate::packs::Severity>,
pub reason: String,
pub source: MatchSource,
pub matched_span: Option<MatchSpan>,
pub matched_text_preview: Option<String>,
pub explanation: Option<String>,
pub suggestions: &'static [PatternSuggestion],
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AllowlistOverride {
pub layer: AllowlistLayer,
pub reason: String,
pub matched: PatternMatch,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MatchSource {
ConfigOverride,
LegacyPattern,
Pack,
HeredocAst,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BranchContext {
pub branch_name: Option<String>,
pub is_protected: bool,
pub is_relaxed: bool,
pub strictness: crate::config::StrictnessLevel,
pub affected_decision: bool,
}
#[derive(Debug, Clone)]
pub struct EvaluationResult {
pub decision: EvaluationDecision,
pub pattern_info: Option<PatternMatch>,
pub allowlist_override: Option<AllowlistOverride>,
pub effective_mode: Option<crate::packs::DecisionMode>,
pub skipped_due_to_budget: bool,
pub branch_context: Option<BranchContext>,
pub session_occurrence: Option<crate::session::OccurrenceSnapshot>,
pub graduated_response: Option<GraduatedResponse>,
pub bypass_method: Option<BypassMethod>,
}
impl EvaluationResult {
#[inline]
#[must_use]
pub const fn allowed() -> Self {
Self {
decision: EvaluationDecision::Allow,
pattern_info: None,
allowlist_override: None,
effective_mode: None,
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub const fn allowed_due_to_budget() -> Self {
Self {
decision: EvaluationDecision::Allow,
pattern_info: None,
allowlist_override: None,
effective_mode: None,
skipped_due_to_budget: true,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub const fn denied_by_config(reason: String) -> Self {
Self {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: None,
pattern_name: None,
severity: None,
reason,
source: MatchSource::ConfigOverride,
matched_span: None,
matched_text_preview: None,
explanation: None,
suggestions: &[],
}),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub fn denied_by_legacy(reason: &str) -> Self {
Self {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: None,
pattern_name: None,
severity: None,
reason: reason.to_string(),
source: MatchSource::LegacyPattern,
matched_span: None,
matched_text_preview: None,
explanation: None,
suggestions: &[],
}),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub fn denied_by_legacy_with_span(reason: &str, command: &str, span: MatchSpan) -> Self {
let preview = extract_match_preview(command, &span);
Self {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: None,
pattern_name: None,
severity: None,
reason: reason.to_string(),
source: MatchSource::LegacyPattern,
matched_span: Some(span),
matched_text_preview: Some(preview),
explanation: None,
suggestions: &[],
}),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub fn denied_by_pack(pack_id: &str, reason: &str, explanation: Option<&str>) -> Self {
Self {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: Some(pack_id.to_string()),
pattern_name: None,
severity: None,
reason: reason.to_string(),
source: MatchSource::Pack,
matched_span: None,
matched_text_preview: None,
explanation: explanation.map(str::to_string),
suggestions: &[],
}),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub fn denied_by_pack_with_span(
pack_id: &str,
reason: &str,
explanation: Option<&str>,
command: &str,
span: MatchSpan,
) -> Self {
let preview = extract_match_preview(command, &span);
Self {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: Some(pack_id.to_string()),
pattern_name: None,
severity: None,
reason: reason.to_string(),
source: MatchSource::Pack,
matched_span: Some(span),
matched_text_preview: Some(preview),
explanation: explanation.map(str::to_string),
suggestions: &[],
}),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub fn denied_by_pack_pattern(
pack_id: &str,
pattern_name: &str,
reason: &str,
explanation: Option<&str>,
severity: crate::packs::Severity,
suggestions: &'static [PatternSuggestion],
) -> Self {
Self {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: Some(pack_id.to_string()),
pattern_name: Some(pattern_name.to_string()),
severity: Some(severity),
reason: reason.to_string(),
source: MatchSource::Pack,
matched_span: None,
matched_text_preview: None,
explanation: explanation.map(str::to_string),
suggestions,
}),
allowlist_override: None,
effective_mode: Some(severity.default_mode()),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub fn denied_by_pack_pattern_with_span(
pack_id: &str,
pattern_name: &str,
reason: &str,
explanation: Option<&str>,
severity: crate::packs::Severity,
suggestions: &'static [PatternSuggestion],
command: &str,
span: MatchSpan,
) -> Self {
let preview = extract_match_preview(command, &span);
Self {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: Some(pack_id.to_string()),
pattern_name: Some(pattern_name.to_string()),
severity: Some(severity),
reason: reason.to_string(),
source: MatchSource::Pack,
matched_span: Some(span),
matched_text_preview: Some(preview),
explanation: explanation.map(str::to_string),
suggestions,
}),
allowlist_override: None,
effective_mode: Some(severity.default_mode()),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[must_use]
pub const fn allowed_by_allowlist(
matched: PatternMatch,
layer: AllowlistLayer,
reason: String,
) -> Self {
Self {
decision: EvaluationDecision::Allow,
pattern_info: None,
allowlist_override: Some(AllowlistOverride {
layer,
reason,
matched,
}),
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
#[inline]
#[must_use]
pub fn is_allowed(&self) -> bool {
self.decision == EvaluationDecision::Allow
}
#[inline]
#[must_use]
pub fn is_denied(&self) -> bool {
self.decision == EvaluationDecision::Deny
}
#[must_use]
pub fn reason(&self) -> Option<&str> {
self.pattern_info.as_ref().map(|p| p.reason.as_str())
}
#[inline]
#[must_use]
pub fn session_count(&self) -> Option<u32> {
self.session_occurrence.as_ref().map(|s| s.session_count)
}
#[must_use]
pub fn pack_id(&self) -> Option<&str> {
self.pattern_info
.as_ref()
.and_then(|p| p.pack_id.as_deref())
}
pub fn apply_graduation(&mut self, config: &crate::config::ResponseConfig) {
self.apply_graduation_with_history_count(None, config);
}
pub fn apply_graduation_with_history_count(
&mut self,
history_count: Option<u32>,
config: &crate::config::ResponseConfig,
) {
if !config.is_enabled() {
return;
}
let session_count = match self.session_occurrence.as_ref() {
Some(snap) => snap.session_count,
None => return,
};
let severity = self
.pattern_info
.as_ref()
.and_then(|p| p.severity)
.unwrap_or(crate::packs::Severity::High);
self.graduated_response = determine_graduated_response_with_history(
session_count,
history_count,
severity,
config,
);
}
pub fn apply_graduation_with_history_db(
&mut self,
command: &str,
history: &crate::history::HistoryDb,
config: &crate::config::ResponseConfig,
) {
if !config.is_enabled() {
return;
}
let window = config.history_window_duration();
let history_count = match history.count_command_blocks_in_window(command, window) {
Ok(n) => Some(n),
Err(e) => {
tracing::debug!(error = %e, "history count query failed; falling back to session-only graduation");
None
}
};
self.apply_graduation_with_history_count(history_count, config);
}
pub fn record_and_graduate(&mut self, command: &str, config: &crate::config::ResponseConfig) {
if self.is_denied() {
let snap = crate::session::record_and_snapshot(command);
self.session_occurrence = Some(snap);
self.apply_graduation(config);
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GraduatedResponse {
Warning { occurrence: u32 },
SoftBlock { occurrence: u32 },
HardBlock { total_occurrences: u32 },
}
impl GraduatedResponse {
#[must_use]
pub const fn blocks(&self) -> bool {
matches!(self, Self::SoftBlock { .. } | Self::HardBlock { .. })
}
#[must_use]
pub const fn is_hard_block(&self) -> bool {
matches!(self, Self::HardBlock { .. })
}
#[must_use]
pub fn decision_mode(&self) -> &'static str {
match self {
Self::Warning { .. } => "warning",
Self::SoftBlock { .. } => "soft_block",
Self::HardBlock { .. } => "hard_block",
}
}
#[must_use]
pub fn label(&self) -> String {
match self {
Self::Warning { occurrence } => format!("warning (occurrence #{occurrence})"),
Self::SoftBlock { occurrence } => format!("soft block (occurrence #{occurrence})"),
Self::HardBlock { total_occurrences } => {
format!("hard block ({total_occurrences} total occurrences)")
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BypassMethod {
Force,
AllowOnce,
}
impl BypassMethod {
#[must_use]
pub const fn label(&self) -> &'static str {
match self {
Self::Force => "force",
Self::AllowOnce => "allow_once",
}
}
}
#[must_use]
pub fn determine_graduated_response(
session_count: u32,
severity: crate::packs::Severity,
config: &crate::config::ResponseConfig,
) -> Option<GraduatedResponse> {
determine_graduated_response_with_history(session_count, None, severity, config)
}
#[must_use]
pub fn determine_graduated_response_with_history(
session_count: u32,
history_count: Option<u32>,
severity: crate::packs::Severity,
config: &crate::config::ResponseConfig,
) -> Option<GraduatedResponse> {
use crate::config::GraduationMode;
if !config.is_enabled() {
return None;
}
let mode = config.effective_mode(severity);
let history_tier = history_count.and_then(|hc| {
if matches!(mode, GraduationMode::Standard | GraduationMode::Lenient) {
if hc >= config.history_hard_block {
Some(GraduatedResponse::HardBlock {
total_occurrences: hc,
})
} else if hc >= config.history_soft_block {
Some(GraduatedResponse::SoftBlock { occurrence: hc })
} else {
None
}
} else {
None
}
});
let session_tier = match mode {
GraduationMode::Disabled => None,
GraduationMode::WarningOnly => Some(GraduatedResponse::Warning {
occurrence: session_count,
}),
GraduationMode::Paranoid => {
Some(GraduatedResponse::HardBlock {
total_occurrences: session_count,
})
}
GraduationMode::Strict => {
if session_count >= config.session_soft_block {
Some(GraduatedResponse::HardBlock {
total_occurrences: session_count,
})
} else {
Some(GraduatedResponse::SoftBlock {
occurrence: session_count,
})
}
}
GraduationMode::Standard => {
if session_count >= config.session_soft_block {
Some(GraduatedResponse::SoftBlock {
occurrence: session_count,
})
} else if session_count >= config.session_warning_count {
Some(GraduatedResponse::Warning {
occurrence: session_count,
})
} else {
None
}
}
GraduationMode::Lenient => {
let warn_threshold = config.session_warning_count.saturating_mul(2);
let soft_threshold = config.session_soft_block.saturating_mul(2);
if session_count >= soft_threshold {
Some(GraduatedResponse::SoftBlock {
occurrence: session_count,
})
} else if session_count >= warn_threshold {
Some(GraduatedResponse::Warning {
occurrence: session_count,
})
} else {
None
}
}
};
match (history_tier, session_tier) {
(Some(h), Some(s)) => Some(strictest(h, s)),
(Some(h), None) => Some(h),
(None, s) => s,
}
}
fn strictest(a: GraduatedResponse, b: GraduatedResponse) -> GraduatedResponse {
fn rank(r: &GraduatedResponse) -> u8 {
match r {
GraduatedResponse::Warning { .. } => 1,
GraduatedResponse::SoftBlock { .. } => 2,
GraduatedResponse::HardBlock { .. } => 3,
}
}
if rank(&a) >= rank(&b) { a } else { b }
}
#[derive(Debug, Clone)]
pub struct DetailedEvaluationResult {
pub result: EvaluationResult,
pub keywords_checked: Vec<String>,
pub evaluation_time_us: u64,
pub confidence: Option<ConfidenceResult>,
pub normalized_command: Option<String>,
pub quick_rejected: bool,
}
impl DetailedEvaluationResult {
#[inline]
#[must_use]
pub fn is_allowed(&self) -> bool {
self.result.is_allowed()
}
#[inline]
#[must_use]
pub fn is_denied(&self) -> bool {
self.result.is_denied()
}
#[inline]
#[must_use]
pub fn into_result(self) -> EvaluationResult {
self.result
}
#[inline]
#[must_use]
pub const fn result(&self) -> &EvaluationResult {
&self.result
}
}
#[must_use]
pub fn evaluate_detailed(command: &str, config: &Config) -> DetailedEvaluationResult {
let allowlists = LayeredAllowlist::default();
evaluate_detailed_with_allowlists(command, config, &allowlists)
}
#[must_use]
pub fn evaluate_detailed_with_allowlists(
command: &str,
config: &Config,
allowlists: &LayeredAllowlist,
) -> DetailedEvaluationResult {
use std::time::Instant;
let start = Instant::now();
let enabled_packs = config.enabled_pack_ids();
let enabled_keywords = REGISTRY.collect_enabled_keywords(&enabled_packs);
let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
let keyword_index = REGISTRY.build_enabled_keyword_index(&ordered_packs);
let heredoc_settings = config.heredoc_settings();
let compiled_overrides = config.overrides.compile();
let quick_rejected = pack_aware_quick_reject(command, &enabled_keywords);
let stripped = strip_wrapper_prefixes(command);
let normalized = crate::normalize::normalize_command(stripped.normalized.as_ref());
let normalized_command = if normalized.as_ref() != command {
Some(normalized.into_owned())
} else {
None
};
let result = evaluate_command_with_pack_order(
command,
&enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
&compiled_overrides,
allowlists,
&heredoc_settings,
);
let evaluation_time_us = start.elapsed().as_micros() as u64;
let confidence = if result.is_denied() {
let sanitized = sanitize_for_pattern_matching(command);
let sanitized_str = if matches!(sanitized, std::borrow::Cow::Owned(_)) {
Some(sanitized.as_ref())
} else {
None
};
let mode = result
.effective_mode
.unwrap_or(crate::packs::DecisionMode::Deny);
Some(apply_confidence_scoring(
command,
sanitized_str,
&result,
mode,
&config.confidence,
))
} else {
None
};
DetailedEvaluationResult {
result,
keywords_checked: enabled_keywords.iter().map(|s| (*s).to_string()).collect(),
evaluation_time_us,
confidence,
normalized_command,
quick_rejected,
}
}
#[must_use]
pub fn evaluate_command(
command: &str,
config: &Config,
enabled_keywords: &[&str],
compiled_overrides: &crate::config::CompiledOverrides,
allowlists: &LayeredAllowlist,
) -> EvaluationResult {
evaluate_command_with_deadline(
command,
config,
enabled_keywords,
compiled_overrides,
allowlists,
None,
)
}
#[inline]
fn deadline_exceeded(deadline: Option<&Deadline>) -> bool {
deadline.is_some_and(Deadline::is_exceeded)
}
#[inline]
fn contains_shell_word_obfuscation(command: &str) -> bool {
command
.as_bytes()
.iter()
.any(|b| matches!(b, b'\\' | b'\'' | b'"'))
}
#[inline]
fn remaining_below(deadline: Option<&Deadline>, budget: &crate::perf::Budget) -> bool {
deadline.is_some_and(|d| !d.has_budget_for(budget))
}
fn resolve_project_path(
heredoc_settings: &crate::config::HeredocSettings,
project_path: Option<&Path>,
) -> Option<PathBuf> {
if heredoc_settings
.content_allowlist
.as_ref()
.is_none_or(|a| a.projects.is_empty())
{
return None;
}
if let Some(path) = project_path {
return Some(path.to_path_buf());
}
std::env::current_dir().ok()
}
fn allow_once_match(
command: &str,
allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
) -> Option<crate::pending_exceptions::AllowOnceEntry> {
let cwd = std::env::current_dir().ok()?;
let store = AllowOnceStore::new(AllowOnceStore::default_path(Some(&cwd)));
match store.match_command(command, &cwd, Utc::now(), allow_once_audit) {
Ok(Some(entry)) => Some(entry),
_ => None,
}
}
#[allow(dead_code)]
fn allow_once_match_force_config(
command: &str,
allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
) -> Option<crate::pending_exceptions::AllowOnceEntry> {
let cwd = std::env::current_dir().ok()?;
let store = AllowOnceStore::new(AllowOnceStore::default_path(Some(&cwd)));
match store.match_command_force_config(command, &cwd, Utc::now(), allow_once_audit) {
Ok(Some(entry)) => Some(entry),
_ => None,
}
}
#[must_use]
pub fn evaluate_command_with_deadline(
command: &str,
config: &Config,
enabled_keywords: &[&str],
compiled_overrides: &crate::config::CompiledOverrides,
allowlists: &LayeredAllowlist,
deadline: Option<&Deadline>,
) -> EvaluationResult {
let enabled_packs: HashSet<String> = config.enabled_pack_ids();
let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
let keyword_index = REGISTRY.build_enabled_keyword_index(&ordered_packs);
let heredoc_settings = config.heredoc_settings();
evaluate_command_with_pack_order_deadline(
command,
enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
compiled_overrides,
allowlists,
&heredoc_settings,
None,
deadline,
)
}
#[must_use]
pub fn evaluate_command_with_pack_order(
command: &str,
enabled_keywords: &[&str],
ordered_packs: &[String],
keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
compiled_overrides: &crate::config::CompiledOverrides,
allowlists: &LayeredAllowlist,
heredoc_settings: &crate::config::HeredocSettings,
) -> EvaluationResult {
evaluate_command_with_pack_order_at_path(
command,
enabled_keywords,
ordered_packs,
keyword_index,
compiled_overrides,
allowlists,
heredoc_settings,
None,
)
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn evaluate_command_with_pack_order_at_path(
command: &str,
enabled_keywords: &[&str],
ordered_packs: &[String],
keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
compiled_overrides: &crate::config::CompiledOverrides,
allowlists: &LayeredAllowlist,
heredoc_settings: &crate::config::HeredocSettings,
project_path: Option<&Path>,
) -> EvaluationResult {
evaluate_command_with_pack_order_deadline_at_path(
command,
enabled_keywords,
ordered_packs,
keyword_index,
compiled_overrides,
allowlists,
heredoc_settings,
None,
project_path,
None,
)
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn evaluate_command_with_pack_order_deadline(
command: &str,
enabled_keywords: &[&str],
ordered_packs: &[String],
keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
compiled_overrides: &crate::config::CompiledOverrides,
allowlists: &LayeredAllowlist,
heredoc_settings: &crate::config::HeredocSettings,
allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
deadline: Option<&Deadline>,
) -> EvaluationResult {
evaluate_command_with_pack_order_deadline_at_path(
command,
enabled_keywords,
ordered_packs,
keyword_index,
compiled_overrides,
allowlists,
heredoc_settings,
allow_once_audit,
None,
deadline,
)
}
#[must_use]
#[allow(clippy::too_many_arguments)]
#[allow(clippy::too_many_lines)]
pub fn evaluate_command_with_pack_order_deadline_at_path(
command: &str,
enabled_keywords: &[&str],
ordered_packs: &[String],
keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
compiled_overrides: &crate::config::CompiledOverrides,
allowlists: &LayeredAllowlist,
heredoc_settings: &crate::config::HeredocSettings,
allow_once_audit: Option<&crate::pending_exceptions::AllowOnceAuditConfig<'_>>,
project_path: Option<&Path>,
deadline: Option<&Deadline>,
) -> EvaluationResult {
if deadline_exceeded(deadline) {
return EvaluationResult::allowed_due_to_budget();
}
if command.is_empty() {
return EvaluationResult::allowed();
}
if let Some(reason) = compiled_overrides.check_block(command) {
if allow_once_match_force_config(command, allow_once_audit).is_some() {
return EvaluationResult::allowed();
}
return EvaluationResult::denied_by_config(reason.to_string());
}
if compiled_overrides.check_allow(command) {
return EvaluationResult::allowed();
}
if deadline_exceeded(deadline) {
return EvaluationResult::allowed_due_to_budget();
}
let mut precomputed_sanitized = None;
let mut heredoc_allowlist_hit: Option<(PatternMatch, AllowlistLayer, String)> = None;
let project_path = resolve_project_path(heredoc_settings, project_path);
let project_path = project_path.as_deref();
if heredoc_settings.enabled {
if remaining_below(deadline, &crate::perf::HEREDOC_TRIGGER) {
return EvaluationResult::allowed_due_to_budget();
}
if check_triggers(command) == TriggerResult::Triggered {
let sanitized = sanitize_for_pattern_matching(command);
let sanitized_str = sanitized.as_ref();
let should_scan = if matches!(sanitized, std::borrow::Cow::Owned(_)) {
check_triggers(sanitized_str) == TriggerResult::Triggered
} else {
true
};
precomputed_sanitized = Some(sanitized);
if should_scan {
let context = HeredocEvaluationContext {
allowlists,
heredoc_settings,
project_path,
deadline,
enabled_keywords,
ordered_packs,
keyword_index,
compiled_overrides,
allow_once_audit,
};
if let Some(blocked) =
evaluate_heredoc(command, context, &mut heredoc_allowlist_hit)
{
return blocked;
}
}
}
}
if deadline_exceeded(deadline) {
return EvaluationResult::allowed_due_to_budget();
}
if let Some(index) = keyword_index {
if !index.has_any_keyword(command) && !contains_shell_word_obfuscation(command) {
if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
return EvaluationResult::allowed();
}
} else if pack_aware_quick_reject(command, enabled_keywords) {
if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
return EvaluationResult::allowed();
}
if deadline_exceeded(deadline) {
return EvaluationResult::allowed_due_to_budget();
}
let sanitized = precomputed_sanitized.unwrap_or_else(|| sanitize_for_pattern_matching(command));
let command_for_match = sanitized.as_ref();
let (quick_reject, normalized) =
pack_aware_quick_reject_with_normalized(command_for_match, enabled_keywords);
if quick_reject
&& !should_check_original_control_plane_payload_for_any_pack(
command_for_match,
command,
ordered_packs,
)
{
if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
return EvaluationResult::allowed();
}
if deadline_exceeded(deadline) {
return EvaluationResult::allowed_due_to_budget();
}
if allow_once_match(command, allow_once_audit).is_some() {
return EvaluationResult::allowed();
}
if allowlists
.match_exact_command_at_path(&normalized, project_path)
.is_some()
|| allowlists
.match_command_prefix_at_path(&normalized, project_path)
.is_some()
|| allowlists
.match_pattern_at_path(&normalized, project_path)
.is_some()
{
return EvaluationResult::allowed();
}
let masked = crate::heredoc::mask_non_executing_heredocs(&normalized);
let command_for_packs = masked.as_ref();
let result = evaluate_packs_with_allowlists(
command_for_packs,
&normalized,
command_for_match,
command,
ordered_packs,
allowlists,
keyword_index,
None,
project_path,
);
if result.allowlist_override.is_none() {
if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
}
result
}
#[allow(clippy::too_many_lines)]
#[allow(clippy::too_many_arguments)]
fn evaluate_packs_with_allowlists(
command_for_packs: &str,
normalized: &str,
command_for_match: &str,
original_command: &str,
ordered_packs: &[String],
allowlists: &LayeredAllowlist,
keyword_index: Option<&crate::packs::EnabledKeywordIndex>,
deadline: Option<&Deadline>,
project_path: Option<&Path>,
) -> EvaluationResult {
if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH) {
return EvaluationResult::allowed_due_to_budget();
}
let external_store = crate::packs::get_external_packs();
let candidate_packs: Vec<(&String, &crate::packs::Pack)> = keyword_index.map_or_else(
|| {
ordered_packs
.iter()
.filter_map(|pack_id| {
if let Some(entry) = REGISTRY.get_entry(pack_id) {
if !entry.might_match(command_for_packs)
&& !should_check_original_control_plane_payload(
pack_id,
command_for_packs,
original_command,
)
{
return None;
}
return Some((pack_id, entry.get_pack()));
}
if let Some(store) = external_store {
if let Some(pack) = store.get(pack_id) {
if !pack.might_match(command_for_packs)
&& !should_check_original_control_plane_payload(
pack_id,
command_for_packs,
original_command,
)
{
return None;
}
return Some((pack_id, pack));
}
}
None
})
.collect()
},
|index| {
let mask = index.candidate_pack_mask(command_for_packs);
ordered_packs
.iter()
.enumerate()
.filter_map(|(i, pack_id)| {
if (mask >> i) & 1 == 0
&& !should_check_original_control_plane_payload(
pack_id,
command_for_packs,
original_command,
)
{
return None;
}
if let Some(entry) = REGISTRY.get_entry(pack_id) {
return Some((pack_id, entry.get_pack()));
}
if let Some(store) = external_store {
if let Some(pack) = store.get(pack_id) {
return Some((pack_id, pack));
}
}
None
})
.collect()
},
);
let has_filesystem_pack = candidate_packs
.iter()
.any(|(pack_id, _)| pack_id.as_str() == "core.filesystem");
let rm_parse = has_filesystem_pack
.then(|| crate::packs::core::filesystem::parse_rm_command(command_for_packs));
let normalized_offset = compute_normalized_offset(command_for_match, normalized);
let original_len = original_command.len();
let segment_ranges = command_segment_ranges(command_for_packs);
let has_compound_segments = segment_ranges.len() > 1;
let mut first_allowlist_hit: Option<(PatternMatch, AllowlistLayer, String)> = None;
for &(pack_id, pack) in &candidate_packs {
if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH) {
return EvaluationResult::allowed_due_to_budget();
}
if pack_id == "core.filesystem" {
let has_pre_rm_propagation_match = pack.destructive_patterns.iter().any(|pattern| {
crate::packs::core::filesystem::is_pre_rm_propagation_rule(pattern.name)
&& pattern.regex.is_match(command_for_packs)
});
match rm_parse.as_ref() {
Some(crate::packs::core::filesystem::RmParseDecision::Allow)
if !has_pre_rm_propagation_match =>
{
continue; }
Some(crate::packs::core::filesystem::RmParseDecision::Allow) => {
}
Some(crate::packs::core::filesystem::RmParseDecision::NoMatch) | None => {
if pack.matches_safe_with_deadline(command_for_packs, deadline) {
continue;
}
}
Some(crate::packs::core::filesystem::RmParseDecision::Deny(hit)) => {
if let Some(allow_hit) =
allowlists.match_rule_at_path(pack_id, hit.pattern_name, project_path)
{
if first_allowlist_hit.is_none() {
let span = hit.span.as_ref().map(|span| MatchSpan {
start: span.start,
end: span.end,
});
let mapped_span = span.and_then(|span| {
map_span_with_offset(span, normalized_offset, original_len)
});
let preview = mapped_span
.as_ref()
.map(|span| extract_match_preview(original_command, span))
.or_else(|| {
span.as_ref()
.map(|span| extract_match_preview(command_for_packs, span))
});
first_allowlist_hit = Some((
PatternMatch {
pack_id: Some(pack_id.clone()),
pattern_name: Some(hit.pattern_name.to_string()),
severity: Some(hit.severity),
reason: hit.reason.to_string(),
source: MatchSource::Pack,
matched_span: mapped_span,
matched_text_preview: preview,
explanation: None,
suggestions: &[],
},
allow_hit.layer,
allow_hit.entry.reason.clone(),
));
}
continue;
}
if let Some(span) = hit.span.as_ref().map(|span| MatchSpan {
start: span.start,
end: span.end,
}) {
if let Some(mapped_span) =
map_span_with_offset(span, normalized_offset, original_len)
{
return EvaluationResult::denied_by_pack_pattern_with_span(
pack_id,
hit.pattern_name,
hit.reason,
None,
hit.severity,
&[], original_command,
mapped_span,
);
}
}
return EvaluationResult::denied_by_pack_pattern(
pack_id,
hit.pattern_name,
hit.reason,
None,
hit.severity,
&[], );
}
}
} else if has_compound_segments {
for &(segment_start, segment_end) in &segment_ranges {
if deadline_exceeded(deadline)
|| remaining_below(deadline, &crate::perf::PATTERN_MATCH)
{
return EvaluationResult::allowed_due_to_budget();
}
let segment = &command_for_packs[segment_start..segment_end];
let sanitized_segment = sanitize_for_pattern_matching(segment);
let segment_for_match = sanitized_segment.as_ref();
if pack.matches_safe_with_deadline(segment_for_match, deadline) {
continue;
}
let nested_segment_ranges: Vec<(usize, usize)> = segment_ranges
.iter()
.copied()
.filter(|&(nested_start, nested_end)| {
nested_start >= segment_start
&& nested_end <= segment_end
&& !(nested_start == segment_start && nested_end == segment_end)
})
.collect();
if let Some(result) = evaluate_pack_destructive_patterns(
pack_id,
pack,
segment_for_match,
segment_start,
original_command,
normalized_offset,
original_len,
allowlists,
project_path,
&mut first_allowlist_hit,
deadline,
&nested_segment_ranges,
) {
return result;
}
}
} else if pack.matches_safe_with_deadline(command_for_packs, deadline) {
continue; }
for pattern in &pack.destructive_patterns {
if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH)
{
return EvaluationResult::allowed_due_to_budget();
}
let matched_span = pattern
.regex
.find(command_for_packs)
.map(|(start, end)| MatchSpan { start, end });
if deadline_exceeded(deadline) {
return EvaluationResult::allowed_due_to_budget();
}
let Some(span) = matched_span else {
continue;
};
if has_compound_segments
&& pack_id != "core.filesystem"
&& span_is_inside_any_segment(span, &segment_ranges)
{
continue;
}
let reason = pattern.reason;
let mapped_span = map_span_with_offset(span, normalized_offset, original_len);
let preview = mapped_span
.as_ref()
.map(|span| extract_match_preview(original_command, span))
.or_else(|| Some(extract_match_preview(command_for_packs, &span)));
if let Some(pattern_name) = pattern.name {
if let Some(hit) =
allowlists.match_rule_at_path(pack_id, pattern_name, project_path)
{
if first_allowlist_hit.is_none() {
first_allowlist_hit = Some((
PatternMatch {
pack_id: Some(pack_id.clone()),
pattern_name: Some(pattern_name.to_string()),
severity: Some(pattern.severity),
reason: reason.to_string(),
source: MatchSource::Pack,
matched_span: mapped_span,
matched_text_preview: preview,
explanation: pattern.explanation.map(str::to_string),
suggestions: pattern.suggestions,
},
hit.layer,
hit.entry.reason.clone(),
));
}
continue;
}
if let Some(mapped_span) = mapped_span {
return EvaluationResult::denied_by_pack_pattern_with_span(
pack_id,
pattern_name,
reason,
pattern.explanation,
pattern.severity,
pattern.suggestions,
original_command,
mapped_span,
);
}
return EvaluationResult::denied_by_pack_pattern(
pack_id,
pattern_name,
reason,
pattern.explanation,
pattern.severity,
pattern.suggestions,
);
}
if let Some(mapped_span) = mapped_span {
return EvaluationResult::denied_by_pack_with_span(
pack_id,
reason,
pattern.explanation,
original_command,
mapped_span,
);
}
return EvaluationResult::denied_by_pack(pack_id, reason, pattern.explanation);
}
if let Some(result) = evaluate_original_control_plane_payloads(
pack_id.as_str(),
pack,
command_for_packs,
original_command,
allowlists,
project_path,
&mut first_allowlist_hit,
deadline,
) {
return result;
}
}
if let Some((matched, layer, reason)) = first_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
EvaluationResult::allowed()
}
#[allow(clippy::too_many_arguments)]
fn evaluate_original_control_plane_payloads(
pack_id: &str,
pack: &crate::packs::Pack,
command_for_packs: &str,
original_command: &str,
allowlists: &LayeredAllowlist,
project_path: Option<&Path>,
first_allowlist_hit: &mut Option<(PatternMatch, AllowlistLayer, String)>,
deadline: Option<&Deadline>,
) -> Option<EvaluationResult> {
if !should_check_original_control_plane_payload(pack_id, command_for_packs, original_command) {
return None;
}
let original_len = original_command.len();
let segment_ranges = command_segment_ranges(original_command);
if segment_ranges.len() <= 1 {
let command_slice = control_plane_segment_for_matching(original_command);
return evaluate_pack_destructive_patterns(
pack_id,
pack,
command_slice.as_ref(),
0,
original_command,
Some(0),
original_len,
allowlists,
project_path,
first_allowlist_hit,
deadline,
&[],
);
}
for (segment_start, segment_end) in segment_ranges {
let segment = &original_command[segment_start..segment_end];
if original_control_plane_segment_is_relevant(pack_id, segment) {
let command_slice = control_plane_segment_for_matching(segment);
if let Some(result) = evaluate_pack_destructive_patterns(
pack_id,
pack,
command_slice.as_ref(),
segment_start,
original_command,
Some(0),
original_len,
allowlists,
project_path,
first_allowlist_hit,
deadline,
&[],
) {
return Some(result);
}
}
}
None
}
fn control_plane_segment_for_matching(segment: &str) -> Cow<'_, str> {
if !segment.contains(['\r', '\n']) {
return Cow::Borrowed(segment);
}
let mut normalized = String::with_capacity(segment.len());
for ch in segment.chars() {
if matches!(ch, '\r' | '\n') {
normalized.push(' ');
} else {
normalized.push(ch);
}
}
Cow::Owned(normalized)
}
fn command_segment_ranges(cmd: &str) -> Vec<(usize, usize)> {
crate::packs::split_command_segments(cmd)
.into_iter()
.map(|segment| {
let start = segment.as_ptr() as usize - cmd.as_ptr() as usize;
(start, start + segment.len())
})
.collect()
}
fn span_is_inside_any_segment(span: MatchSpan, segment_ranges: &[(usize, usize)]) -> bool {
segment_ranges
.iter()
.any(|&(start, end)| span.start >= start && span.end <= end)
}
fn should_check_original_control_plane_payload(
pack_id: &str,
command_for_packs: &str,
original_command: &str,
) -> bool {
command_for_packs != original_command
&& matches!(pack_id, "platform.railway")
&& command_contains_curl_invocation(command_for_packs)
&& original_command_contains_railway_api_signal(original_command)
}
fn original_control_plane_segment_is_relevant(pack_id: &str, segment: &str) -> bool {
matches!(pack_id, "platform.railway")
&& command_contains_curl_invocation(segment)
&& original_command_contains_railway_api_signal(segment)
}
fn command_contains_curl_invocation(command: &str) -> bool {
command
.split(|ch: char| ch.is_ascii_whitespace() || matches!(ch, ';' | '&' | '|' | '(' | ')'))
.map(|word| word.trim_matches(['"', '\'']))
.filter_map(|word| word.rsplit(['/', '\\']).next())
.map(|name| {
if name.len() >= 4 && name[name.len() - 4..].eq_ignore_ascii_case(".exe") {
&name[..name.len() - 4]
} else {
name
}
})
.any(|name| name.eq_ignore_ascii_case("curl"))
}
fn should_check_original_control_plane_payload_for_any_pack(
command_for_packs: &str,
original_command: &str,
ordered_packs: &[String],
) -> bool {
ordered_packs.iter().any(|pack_id| {
should_check_original_control_plane_payload(pack_id, command_for_packs, original_command)
})
}
fn original_command_contains_railway_api_signal(command: &str) -> bool {
let case_sensitive_signals = [
"PROJECT_ACCESS_TOKEN",
"RAILWAY_API_TOKEN",
"RAILWAY_API_URL",
"RAILWAY_TOKEN",
];
if case_sensitive_signals
.iter()
.any(|signal| command.contains(signal))
{
return true;
}
let lower_command = command.to_ascii_lowercase();
[
"backboard.railway.app",
"backboard.railway.com",
"project-access-token",
"railway.app/graphql",
"railway.com/graphql",
]
.iter()
.any(|signal| lower_command.contains(signal))
}
#[allow(clippy::too_many_arguments)]
fn evaluate_pack_destructive_patterns(
pack_id: &str,
pack: &crate::packs::Pack,
command_slice: &str,
slice_offset: usize,
original_command: &str,
normalized_offset: Option<usize>,
original_len: usize,
allowlists: &LayeredAllowlist,
project_path: Option<&Path>,
first_allowlist_hit: &mut Option<(PatternMatch, AllowlistLayer, String)>,
deadline: Option<&Deadline>,
ignored_ranges: &[(usize, usize)],
) -> Option<EvaluationResult> {
for pattern in &pack.destructive_patterns {
if deadline_exceeded(deadline) || remaining_below(deadline, &crate::perf::PATTERN_MATCH) {
return Some(EvaluationResult::allowed_due_to_budget());
}
let matched_span = pattern
.regex
.find(command_slice)
.map(|(start, end)| MatchSpan {
start: start + slice_offset,
end: end + slice_offset,
});
if deadline_exceeded(deadline) {
return Some(EvaluationResult::allowed_due_to_budget());
}
let Some(span) = matched_span else {
continue;
};
if span_is_inside_any_segment(span, ignored_ranges) {
continue;
}
let reason = pattern.reason;
let mapped_span = map_span_with_offset(span, normalized_offset, original_len);
let slice_span = MatchSpan {
start: span.start.saturating_sub(slice_offset),
end: span.end.saturating_sub(slice_offset),
};
let preview = mapped_span
.as_ref()
.map(|span| extract_match_preview(original_command, span))
.or_else(|| Some(extract_match_preview(command_slice, &slice_span)));
if let Some(pattern_name) = pattern.name {
if let Some(hit) = allowlists.match_rule_at_path(pack_id, pattern_name, project_path) {
if first_allowlist_hit.is_none() {
*first_allowlist_hit = Some((
PatternMatch {
pack_id: Some(pack_id.to_string()),
pattern_name: Some(pattern_name.to_string()),
severity: Some(pattern.severity),
reason: reason.to_string(),
source: MatchSource::Pack,
matched_span: mapped_span,
matched_text_preview: preview,
explanation: pattern.explanation.map(str::to_string),
suggestions: pattern.suggestions,
},
hit.layer,
hit.entry.reason.clone(),
));
}
continue;
}
if let Some(mapped_span) = mapped_span {
return Some(EvaluationResult::denied_by_pack_pattern_with_span(
pack_id,
pattern_name,
reason,
pattern.explanation,
pattern.severity,
pattern.suggestions,
original_command,
mapped_span,
));
}
return Some(EvaluationResult::denied_by_pack_pattern(
pack_id,
pattern_name,
reason,
pattern.explanation,
pattern.severity,
pattern.suggestions,
));
}
if let Some(mapped_span) = mapped_span {
return Some(EvaluationResult::denied_by_pack_with_span(
pack_id,
reason,
pattern.explanation,
original_command,
mapped_span,
));
}
return Some(EvaluationResult::denied_by_pack(
pack_id,
reason,
pattern.explanation,
));
}
None
}
#[allow(clippy::too_many_lines)]
pub fn evaluate_command_with_legacy<S, D>(
command: &str,
config: &Config,
enabled_keywords: &[&str],
compiled_overrides: &crate::config::CompiledOverrides,
allowlists: &LayeredAllowlist,
safe_patterns: &[S],
destructive_patterns: &[D],
) -> EvaluationResult
where
S: LegacySafePattern,
D: LegacyDestructivePattern,
{
if command.is_empty() {
return EvaluationResult::allowed();
}
let allow_once = allow_once_match(command, None);
if let Some(reason) = compiled_overrides.check_block(command) {
if allow_once
.as_ref()
.is_some_and(|entry| entry.force_allow_config)
{
return EvaluationResult::allowed();
}
return EvaluationResult::denied_by_config(reason.to_string());
}
if compiled_overrides.check_allow(command) {
return EvaluationResult::allowed();
}
if allow_once.is_some() {
return EvaluationResult::allowed();
}
let enabled_packs: HashSet<String> = config.enabled_pack_ids();
let ordered_packs = REGISTRY.expand_enabled_ordered(&enabled_packs);
let keyword_index = REGISTRY.build_enabled_keyword_index(&ordered_packs);
let heredoc_settings = config.heredoc_settings();
let mut precomputed_sanitized = None;
let mut heredoc_allowlist_hit: Option<(PatternMatch, AllowlistLayer, String)> = None;
let project_path = resolve_project_path(&heredoc_settings, None);
let project_path = project_path.as_deref();
if heredoc_settings.enabled && check_triggers(command) == TriggerResult::Triggered {
let sanitized = sanitize_for_pattern_matching(command);
let sanitized_str = sanitized.as_ref();
let should_scan = if matches!(sanitized, std::borrow::Cow::Owned(_)) {
check_triggers(sanitized_str) == TriggerResult::Triggered
} else {
true
};
precomputed_sanitized = Some(sanitized);
if should_scan {
let context = HeredocEvaluationContext {
allowlists,
heredoc_settings: &heredoc_settings,
project_path,
deadline: None,
enabled_keywords,
ordered_packs: &ordered_packs,
keyword_index: keyword_index.as_ref(),
compiled_overrides,
allow_once_audit: None,
};
if let Some(blocked) = evaluate_heredoc(command, context, &mut heredoc_allowlist_hit) {
return blocked;
}
}
}
if pack_aware_quick_reject(command, enabled_keywords) {
if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
return EvaluationResult::allowed();
}
let sanitized = precomputed_sanitized.unwrap_or_else(|| sanitize_for_pattern_matching(command));
let command_for_match = sanitized.as_ref();
let (quick_reject, normalized) =
pack_aware_quick_reject_with_normalized(command_for_match, enabled_keywords);
if quick_reject {
if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
return EvaluationResult::allowed();
}
for pattern in safe_patterns {
if pattern.is_match(&normalized) {
return EvaluationResult::allowed();
}
}
let normalized_offset = compute_normalized_offset(command_for_match, &normalized);
let original_len = command.len();
for pattern in destructive_patterns {
if let Some(span) = pattern.find_span(&normalized) {
if let Some(mapped_span) = map_span_with_offset(span, normalized_offset, original_len) {
return EvaluationResult::denied_by_legacy_with_span(
pattern.reason(),
command,
mapped_span,
);
}
return EvaluationResult::denied_by_legacy(pattern.reason());
}
}
let result = evaluate_packs_with_allowlists(
&normalized,
&normalized,
command_for_match,
command,
&ordered_packs,
allowlists,
keyword_index.as_ref(),
None,
None, );
if result.allowlist_override.is_none() {
if let Some((matched, layer, reason)) = heredoc_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
}
result
}
#[derive(Clone, Copy)]
struct HeredocEvaluationContext<'a> {
allowlists: &'a LayeredAllowlist,
heredoc_settings: &'a crate::config::HeredocSettings,
project_path: Option<&'a Path>,
deadline: Option<&'a Deadline>,
enabled_keywords: &'a [&'a str],
ordered_packs: &'a [String],
keyword_index: Option<&'a crate::packs::EnabledKeywordIndex>,
compiled_overrides: &'a crate::config::CompiledOverrides,
allow_once_audit: Option<&'a crate::pending_exceptions::AllowOnceAuditConfig<'a>>,
}
#[allow(clippy::too_many_lines)]
fn evaluate_heredoc(
command: &str,
context: HeredocEvaluationContext<'_>,
first_allowlist_hit: &mut Option<(PatternMatch, AllowlistLayer, String)>,
) -> Option<EvaluationResult> {
if deadline_exceeded(context.deadline)
|| remaining_below(context.deadline, &crate::perf::FULL_HEREDOC_PIPELINE)
{
return Some(EvaluationResult::allowed_due_to_budget());
}
if let Some(ref content_allowlist) = context.heredoc_settings.content_allowlist {
if let Some(matched_cmd) = content_allowlist.is_command_allowlisted(command) {
tracing::debug!(matched_command = matched_cmd, "heredoc command allowlisted");
return None;
}
}
let (contents, fallback_needed) =
match extract_content(command, &context.heredoc_settings.limits) {
ExtractionResult::Extracted(contents) => (contents, false),
ExtractionResult::NoContent => return None,
ExtractionResult::Skipped(reasons) => {
let is_timeout = reasons
.iter()
.any(|r| matches!(r, SkipReason::Timeout { .. }));
let strict_timeout = is_timeout && !context.heredoc_settings.fallback_on_timeout;
let strict_other = !is_timeout && !context.heredoc_settings.fallback_on_parse_error;
if strict_timeout || strict_other {
let summary = reasons
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
let reason = if strict_timeout {
format!(
"Embedded code blocked: extraction exceeded timeout and \
fallback_on_timeout=false ({summary})"
)
} else {
format!(
"Embedded code blocked: extraction skipped and \
fallback_on_parse_error=false ({summary})"
)
};
return Some(EvaluationResult::denied_by_legacy(&reason));
}
if reasons
.iter()
.any(|r| matches!(r, SkipReason::ExceededSizeLimit { .. }))
{
if let Some(blocked) = check_fallback_patterns(command) {
return Some(blocked);
}
}
return None;
}
ExtractionResult::Partial { extracted, skipped } => {
let is_timeout = skipped
.iter()
.any(|r| matches!(r, SkipReason::Timeout { .. }));
let strict_timeout = is_timeout && !context.heredoc_settings.fallback_on_timeout;
let strict_other = !is_timeout && !context.heredoc_settings.fallback_on_parse_error;
if strict_timeout || strict_other {
let summary = skipped
.iter()
.map(std::string::ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
let reason = if strict_timeout {
format!(
"Embedded code blocked: extraction exceeded timeout (partial) and \
fallback_on_timeout=false ({summary})"
)
} else {
format!(
"Embedded code blocked: extraction partial and \
fallback_on_parse_error=false ({summary})"
)
};
return Some(EvaluationResult::denied_by_legacy(&reason));
}
let fallback_needed = skipped
.iter()
.any(|r| matches!(r, SkipReason::ExceededSizeLimit { .. }));
(extracted, fallback_needed)
}
ExtractionResult::Failed(err) => {
if !context.heredoc_settings.fallback_on_parse_error {
let reason = format!(
"Embedded code blocked: extraction failed and \
fallback_on_parse_error=false ({err})"
);
return Some(EvaluationResult::denied_by_legacy(&reason));
}
return None;
}
};
for content in contents {
if deadline_exceeded(context.deadline)
|| remaining_below(context.deadline, &crate::perf::FULL_HEREDOC_PIPELINE)
{
return Some(EvaluationResult::allowed_due_to_budget());
}
if let Some(allowed) = &context.heredoc_settings.allowed_languages {
if !allowed.contains(&content.language) {
continue;
}
}
if let Some(ref content_allowlist) = context.heredoc_settings.content_allowlist {
if let Some(hit) = content_allowlist.is_content_allowlisted(
&content.content,
content.language,
context.project_path,
) {
tracing::debug!(
hit_kind = hit.kind.label(),
matched = hit.matched,
reason = hit.reason,
"heredoc content allowlisted"
);
continue;
}
}
if content
.target_command
.as_ref()
.is_some_and(|cmd| crate::heredoc::is_non_executing_heredoc_command(cmd))
{
tracing::trace!(
target_command = ?content.target_command,
"Skipping heredoc content analysis for non-executing target"
);
continue; }
if content.language == crate::heredoc::ScriptLanguage::Bash {
let body_has_keywords = context.keyword_index.map_or_else(
|| {
context.enabled_keywords.iter().any(|kw| {
memchr::memmem::find(content.content.as_bytes(), kw.as_bytes()).is_some()
})
},
|index| index.has_any_keyword(&content.content),
);
if body_has_keywords {
let inner_commands = crate::heredoc::extract_shell_commands(&content.content);
for inner in inner_commands {
if deadline_exceeded(context.deadline) {
return Some(EvaluationResult::allowed_due_to_budget());
}
let result = evaluate_command_with_pack_order_deadline_at_path(
&inner.text,
context.enabled_keywords,
context.ordered_packs,
context.keyword_index,
context.compiled_overrides,
context.allowlists,
context.heredoc_settings,
context.allow_once_audit,
context.project_path,
context.deadline,
);
if result.is_denied() {
if let Some(mut info) = result.pattern_info {
info.reason = format!(
"Embedded shell command blocked: {} (line {} of heredoc)",
info.reason, inner.line_number
);
info.source = MatchSource::HeredocAst; if let Some(span) = info.matched_span {
if let Some(mapped_inner) =
map_heredoc_span(command, &content, inner.start, inner.end)
{
let mapped = MatchSpan {
start: mapped_inner.start.saturating_add(span.start),
end: mapped_inner.start.saturating_add(span.end),
};
if mapped.end <= command.len() {
info.matched_span = Some(mapped);
info.matched_text_preview =
Some(extract_match_preview(command, &mapped));
} else {
info.matched_span = None;
}
} else {
info.matched_span = None;
}
}
return Some(EvaluationResult {
decision: EvaluationDecision::Deny,
pattern_info: Some(info),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
});
}
return Some(result);
}
}
} }
let matches = match DEFAULT_MATCHER.find_matches(&content.content, content.language) {
Ok(matches) => matches,
Err(err) => {
let is_timeout = matches!(err, crate::ast_matcher::MatchError::Timeout { .. });
let strict_timeout = is_timeout && !context.heredoc_settings.fallback_on_timeout;
let strict_other = !is_timeout && !context.heredoc_settings.fallback_on_parse_error;
if strict_timeout || strict_other {
let reason = format!(
"Embedded code blocked: AST matching error with strict fallback \
configuration ({err})"
);
return Some(EvaluationResult::denied_by_legacy(&reason));
}
continue;
}
};
for m in matches {
if deadline_exceeded(context.deadline)
|| remaining_below(context.deadline, &crate::perf::FULL_HEREDOC_PIPELINE)
{
return Some(EvaluationResult::allowed_due_to_budget());
}
if !m.severity.blocks_by_default() {
continue;
}
let (pack_id, pattern_name) = split_ast_rule_id(&m.rule_id);
if let Some(hit) = context.allowlists.match_rule(&pack_id, &pattern_name) {
if first_allowlist_hit.is_none() {
let reason =
format_heredoc_denial_reason(&content, &m, &pack_id, &pattern_name);
let mapped_span = map_heredoc_span(command, &content, m.start, m.end);
*first_allowlist_hit = Some((
PatternMatch {
pack_id: Some(pack_id),
pattern_name: Some(pattern_name),
severity: Some(ast_severity_to_pack_severity(m.severity)),
reason,
source: MatchSource::HeredocAst,
matched_span: mapped_span,
matched_text_preview: Some(m.matched_text_preview),
explanation: None,
suggestions: &[],
},
hit.layer,
hit.entry.reason.clone(),
));
}
continue;
}
let reason = format_heredoc_denial_reason(&content, &m, &pack_id, &pattern_name);
let mapped_span = map_heredoc_span(command, &content, m.start, m.end);
return Some(EvaluationResult {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: Some(pack_id),
pattern_name: Some(pattern_name),
severity: Some(ast_severity_to_pack_severity(m.severity)),
reason,
source: MatchSource::HeredocAst,
matched_span: mapped_span,
matched_text_preview: Some(m.matched_text_preview),
explanation: None,
suggestions: &[],
}),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
});
}
}
if fallback_needed {
if let Some(blocked) = check_fallback_patterns(command) {
return Some(blocked);
}
}
None
}
#[allow(dead_code)]
fn check_fallback_patterns(command: &str) -> Option<EvaluationResult> {
static FALLBACK_PATTERNS: LazyLock<RegexSet> = LazyLock::new(|| {
RegexSet::new([
r"shutil\.rmtree",
r"os\.remove",
r"os\.rmdir",
r"os\.unlink",
r"fs\.rmSync",
r"fs\.rmdirSync",
r"child_process\.execSync",
r"child_process\.spawnSync",
r"os\.RemoveAll",
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",
])
.expect("fallback patterns must compile")
});
let sanitized = sanitize_for_pattern_matching(command);
let check_target = sanitized.as_ref();
if FALLBACK_PATTERNS.is_match(check_target) {
return Some(EvaluationResult::denied_by_legacy(
"Oversized command contains destructive pattern (fallback check)",
));
}
None
}
fn split_ast_rule_id(rule_id: &str) -> (String, String) {
if let Some(rest) = rule_id.strip_prefix("heredoc.") {
if let Some((lang, tail)) = rest.split_once('.') {
let pack_id = format!("heredoc.{lang}");
return (pack_id, tail.to_string());
}
return ("heredoc".to_string(), rule_id.to_string());
}
if let Some((pack_id, pattern_name)) = rule_id.rsplit_once('.') {
return (pack_id.to_string(), pattern_name.to_string());
}
("unknown".to_string(), rule_id.to_string())
}
fn format_heredoc_denial_reason(
extracted: &crate::heredoc::ExtractedContent,
m: &crate::ast_matcher::PatternMatch,
pack_id: &str,
pattern_name: &str,
) -> String {
let lang = match extracted.language {
crate::heredoc::ScriptLanguage::Bash => "bash",
crate::heredoc::ScriptLanguage::Go => "go",
crate::heredoc::ScriptLanguage::Python => "python",
crate::heredoc::ScriptLanguage::Ruby => "ruby",
crate::heredoc::ScriptLanguage::Perl => "perl",
crate::heredoc::ScriptLanguage::JavaScript => "javascript",
crate::heredoc::ScriptLanguage::TypeScript => "typescript",
crate::heredoc::ScriptLanguage::Php => "php",
crate::heredoc::ScriptLanguage::Unknown => "unknown",
};
format!(
"Embedded {lang} code blocked: {} (rule {pack_id}:{pattern_name}, line {}, matched: {})",
m.reason, m.line_number, m.matched_text_preview
)
}
fn map_heredoc_span(
command: &str,
content: &crate::heredoc::ExtractedContent,
start: usize,
end: usize,
) -> Option<MatchSpan> {
let range = content.content_range.as_ref()?;
let raw = command.get(range.clone())?;
if raw.len() != content.content.len() {
return None;
}
if raw != content.content {
return None;
}
let mapped_start = range.start.saturating_add(start);
let mapped_end = range.start.saturating_add(end);
if mapped_start <= mapped_end && mapped_end <= command.len() {
Some(MatchSpan {
start: mapped_start,
end: mapped_end,
})
} else {
None
}
}
pub trait LegacySafePattern {
fn is_match(&self, cmd: &str) -> bool;
}
pub trait LegacyDestructivePattern {
fn is_match(&self, cmd: &str) -> bool;
fn find_span(&self, cmd: &str) -> Option<MatchSpan> {
let _ = cmd;
None
}
fn reason(&self) -> &str;
}
impl LegacySafePattern for crate::packs::SafePattern {
fn is_match(&self, cmd: &str) -> bool {
self.regex.is_match(cmd)
}
}
impl LegacyDestructivePattern for crate::packs::DestructivePattern {
fn is_match(&self, cmd: &str) -> bool {
self.regex.is_match(cmd)
}
fn find_span(&self, cmd: &str) -> Option<MatchSpan> {
self.regex
.find(cmd)
.map(|(start, end)| MatchSpan { start, end })
}
fn reason(&self) -> &str {
self.reason
}
}
#[derive(Debug, Clone)]
pub struct ConfidenceResult {
pub mode: crate::packs::DecisionMode,
pub score: Option<crate::confidence::ConfidenceScore>,
pub downgraded: bool,
}
#[must_use]
pub fn apply_confidence_scoring(
command: &str,
sanitized_command: Option<&str>,
result: &EvaluationResult,
current_mode: crate::packs::DecisionMode,
config: &crate::config::ConfidenceConfig,
) -> ConfidenceResult {
if !config.enabled {
return ConfidenceResult {
mode: current_mode,
score: None,
downgraded: false,
};
}
if current_mode != crate::packs::DecisionMode::Deny {
return ConfidenceResult {
mode: current_mode,
score: None,
downgraded: false,
};
}
let Some(info) = &result.pattern_info else {
return ConfidenceResult {
mode: current_mode,
score: None,
downgraded: false,
};
};
if config.protect_critical
&& info
.severity
.is_some_and(|s| s == crate::packs::Severity::Critical)
{
return ConfidenceResult {
mode: current_mode,
score: None,
downgraded: false,
};
}
let Some(span) = &info.matched_span else {
return ConfidenceResult {
mode: current_mode,
score: None,
downgraded: false,
};
};
let ctx = crate::confidence::ConfidenceContext {
command,
sanitized_command,
match_start: span.start,
match_end: span.end,
};
let score = crate::confidence::compute_match_confidence(&ctx);
let should_downgrade = score.is_low(config.warn_threshold);
let new_mode = if should_downgrade {
crate::packs::DecisionMode::Warn
} else {
current_mode
};
ConfidenceResult {
mode: new_mode,
score: Some(score),
downgraded: should_downgrade,
}
}
#[must_use]
pub fn apply_branch_strictness(
mut result: EvaluationResult,
config: &Config,
project_path: Option<&Path>,
) -> EvaluationResult {
let git_awareness = &config.git_awareness;
if !git_awareness.enabled {
return result;
}
let branch_info = match project_path {
Some(path) => crate::git::get_branch_info_at_path(path),
None => crate::git::get_branch_info(),
};
let is_detached_head = matches!(&branch_info, crate::git::BranchInfo::DetachedHead(_));
let branch_name = match &branch_info {
crate::git::BranchInfo::Branch(name) => Some(name.clone()),
crate::git::BranchInfo::DetachedHead(_) => None,
crate::git::BranchInfo::NotGitRepo => {
tracing::debug!(
"Not in git repository, using default strictness (git_awareness enabled but no repo detected)"
);
if config.git_awareness.warn_if_not_git {
tracing::warn!(
"dcg git_awareness is enabled but not in a git repository - using default strictness"
);
}
return result;
}
};
let is_protected = branch_name
.as_ref()
.is_some_and(|name| git_awareness.is_protected_branch(Some(name.as_str())));
let is_relaxed = branch_name
.as_ref()
.is_some_and(|name| git_awareness.is_relaxed_branch(Some(name.as_str())));
let strictness = if is_detached_head {
git_awareness.detached_head_strictness
} else {
git_awareness.strictness_for_branch(branch_name.as_deref())
};
let mut affected_decision = false;
if result.decision == EvaluationDecision::Deny {
if let Some(ref pattern_info) = result.pattern_info {
if let Some(severity) = pattern_info.severity {
if !strictness.should_block(severity) {
result.decision = EvaluationDecision::Allow;
affected_decision = true;
}
}
}
}
result.branch_context = Some(BranchContext {
branch_name,
is_protected,
is_relaxed,
strictness,
affected_decision,
});
result
}
#[cfg(test)]
mod tests {
use super::*;
use crate::allowlist::{
AllowEntry, AllowSelector, AllowlistFile, LoadedAllowlistLayer, RuleId,
};
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
static COUNTER: AtomicUsize = AtomicUsize::new(0);
fn default_config() -> Config {
Config::default()
}
fn default_compiled_overrides() -> crate::config::CompiledOverrides {
crate::config::CompiledOverrides::default()
}
fn default_allowlists() -> LayeredAllowlist {
LayeredAllowlist::default()
}
fn evaluate_with_pack_ids(command: &str, pack_ids: &[&str]) -> EvaluationResult {
let enabled_packs: std::collections::HashSet<String> =
pack_ids.iter().map(|id| (*id).to_string()).collect();
let ordered_packs = crate::packs::REGISTRY.expand_enabled_ordered(&enabled_packs);
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let enabled_keywords = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let heredoc_settings = default_config().heredoc_settings();
evaluate_command_with_pack_order(
command,
enabled_keywords.as_slice(),
ordered_packs.as_slice(),
keyword_index.as_ref(),
&compiled,
&allowlists,
&heredoc_settings,
)
}
fn project_allowlists_for_rule(rule: &str, reason: &str) -> LayeredAllowlist {
let rule = RuleId::parse(rule).expect("rule id must parse");
LayeredAllowlist {
layers: vec![LoadedAllowlistLayer {
layer: AllowlistLayer::Project,
path: PathBuf::from("project-allowlist.toml"),
file: AllowlistFile {
entries: vec![AllowEntry {
selector: AllowSelector::Rule(rule),
reason: reason.to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
}],
errors: Vec::new(),
},
}],
}
}
#[allow(dead_code)]
fn project_allowlists_for_pack_wildcard(pack_id: &str, reason: &str) -> LayeredAllowlist {
LayeredAllowlist {
layers: vec![LoadedAllowlistLayer {
layer: AllowlistLayer::Project,
path: PathBuf::from("project-allowlist.toml"),
file: AllowlistFile {
entries: vec![AllowEntry {
selector: AllowSelector::Rule(RuleId {
pack_id: pack_id.to_string(),
pattern_name: "*".to_string(),
}),
reason: reason.to_string(),
added_by: None,
added_at: None,
expires_at: None,
ttl: None,
session: None,
session_id: None,
context: None,
conditions: HashMap::new(),
environments: Vec::new(),
paths: None,
risk_acknowledged: false,
}],
errors: Vec::new(),
},
}],
}
}
#[test]
fn test_empty_command_allowed() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let result = evaluate_command("", &config, &[], &compiled, &allowlists);
assert!(result.is_allowed());
assert!(result.pattern_info.is_none());
}
#[test]
fn test_safe_command_allowed() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let result = evaluate_command("ls -la", &config, &["git", "rm"], &compiled, &allowlists);
assert!(result.is_allowed());
}
#[test]
fn non_core_safe_segment_does_not_mask_later_destructive_segment() {
let result = evaluate_with_pack_ids(
"railway service list && railway volume delete --volume prod-db --yes",
&["platform.railway"],
);
assert!(result.is_denied(), "Railway volume delete must be blocked");
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
}
#[test]
fn non_core_safe_pipeline_stage_does_not_mask_later_destructive_stage() {
let result = evaluate_with_pack_ids(
"railway service list | railway volume delete --volume prod-db --yes",
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway volume delete must be blocked after a safe pipeline stage"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
}
#[test]
fn non_core_safe_background_command_does_not_mask_later_destructive_command() {
let result = evaluate_with_pack_ids(
"railway service list & railway volume delete --volume prod-db --yes",
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway volume delete must be blocked after a safe background command"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
}
#[test]
fn non_core_safe_segment_does_not_mask_earlier_destructive_segment() {
let result = evaluate_with_pack_ids(
"railway volume delete --volume prod-db --yes && railway service list",
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway volume delete must be blocked before a safe segment"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(info.pattern_name.as_deref(), Some("railway-volume-delete"));
}
#[test]
fn non_core_safe_segments_remain_allowed() {
let result = evaluate_with_pack_ids(
"railway service list && railway volume list --json",
&["platform.railway"],
);
assert!(
result.is_allowed(),
"read-only Railway segments should pass"
);
}
#[test]
fn railway_api_mutations_in_curl_payloads_are_not_hidden_by_data_masking() {
let result = evaluate_with_pack_ids(
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"}}}'"#,
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway API variableUpsert payload must be blocked"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(
info.pattern_name.as_deref(),
Some("railway-api-database-variable-upsert")
);
}
#[test]
fn railway_api_payload_recheck_detects_windows_curl_exe() {
for curl_binary in [
r"C:\Windows\System32\curl.exe",
r"C:\Windows\System32\CURL.EXE",
] {
let result = evaluate_with_pack_ids(
&format!(
r#"{curl_binary} https://backboard.railway.app/graphql/v2 --data-binary '{{"query":"mutation {{ projectDelete(id:\"p\") }}"}}'"#
),
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway API mutation through {curl_binary} must still be blocked"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(
info.pattern_name.as_deref(),
Some("railway-api-project-delete")
);
}
}
#[test]
fn railway_api_mutations_with_token_header_are_not_hidden_by_data_masking() {
let result = evaluate_with_pack_ids(
r#"curl https://api.example.com/graphql -H "Authorization: Bearer $RAILWAY_API_TOKEN" --data-binary '{"query":"mutation { projectDelete(id:\"p\") }"}'"#,
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway API mutation authenticated by token header must be blocked"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(
info.pattern_name.as_deref(),
Some("railway-api-project-delete")
);
}
#[test]
fn railway_api_mutations_with_project_access_token_are_not_hidden_by_data_masking() {
let result = evaluate_with_pack_ids(
r#"curl https://api.example.com/graphql -H "Project-Access-Token: $PROJECT_ACCESS_TOKEN" --data-binary '{"query":"mutation { projectDelete(id:\"p\") }"}'"#,
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway API mutation authenticated by Project-Access-Token must be blocked"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(
info.pattern_name.as_deref(),
Some("railway-api-project-delete")
);
}
#[test]
fn railway_api_payload_recheck_does_not_cross_compound_segments() {
let result = evaluate_with_pack_ids(
r#"curl https://backboard.railway.app/graphql/v2 --data-binary '{"query":"query { project(id:\"p\") { id } }"}' && echo projectDelete"#,
&["platform.railway"],
);
assert!(
result.is_allowed(),
"safe Railway API query plus unrelated documentation text should stay allowed"
);
}
#[test]
fn railway_api_payload_recheck_does_not_cross_newline_segments() {
let result = evaluate_with_pack_ids(
"curl https://backboard.railway.app/graphql/v2 --data-binary '{\"query\":\"query { project(id:\\\"p\\\") { id } }\"}'\necho projectDelete",
&["platform.railway"],
);
assert!(
result.is_allowed(),
"safe Railway API query plus newline-separated documentation text should stay allowed"
);
}
#[test]
fn railway_api_payload_recheck_still_blocks_destructive_curl_segment() {
let result = evaluate_with_pack_ids(
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\") }"}'"#,
&["platform.railway"],
);
assert!(
result.is_denied(),
"destructive Railway API mutation in a later curl segment must still be blocked"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(
info.pattern_name.as_deref(),
Some("railway-api-project-delete")
);
}
#[test]
fn railway_api_payload_recheck_handles_shell_line_continuations() {
let result = evaluate_with_pack_ids(
"curl https://backboard.railway.app/graphql/v2 \\\n --data-binary '{\"query\":\"mutation { projectDelete(id:\\\"p\\\") }\"}'",
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway API mutation split with shell line continuation must still be blocked"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(
info.pattern_name.as_deref(),
Some("railway-api-project-delete")
);
}
#[test]
fn railway_api_payload_recheck_handles_multiline_quoted_payloads() {
let result = evaluate_with_pack_ids(
"curl https://backboard.railway.app/graphql/v2 --data-binary '{\n\"query\":\"mutation { projectDelete(id:\\\"p\\\") }\"\n}'",
&["platform.railway"],
);
assert!(
result.is_denied(),
"Railway API mutation inside a multiline quoted payload must still be blocked"
);
let info = result
.pattern_info
.expect("denial should include pattern info");
assert_eq!(info.pack_id.as_deref(), Some("platform.railway"));
assert_eq!(
info.pattern_name.as_deref(),
Some("railway-api-project-delete")
);
}
#[test]
fn masked_non_curl_documentation_stays_allowed_for_railway_api_terms() {
let result = evaluate_with_pack_ids(
r"echo 'projectDelete with RAILWAY_API_TOKEN belongs in docs'",
&["platform.railway"],
);
assert!(
result.is_allowed(),
"masked documentation text should not activate Railway API inspection"
);
}
#[test]
fn masked_non_curl_project_token_documentation_stays_allowed() {
let result = evaluate_with_pack_ids(
r"echo 'projectDelete with Project-Access-Token belongs in docs'",
&["platform.railway"],
);
assert!(
result.is_allowed(),
"masked project-token documentation should not activate Railway API inspection"
);
}
#[test]
fn masked_non_curl_command_name_stays_allowed_for_railway_api_terms() {
let result = evaluate_with_pack_ids(
r#"curlgrep -H "Authorization: Bearer $RAILWAY_API_TOKEN" --data-binary '{"query":"mutation { projectDelete(id:\"p\") }"}'"#,
&["platform.railway"],
);
assert!(
result.is_allowed(),
"non-curl command names should not activate Railway API inspection"
);
}
#[test]
fn test_result_helper_methods() {
let allowed = EvaluationResult::allowed();
assert!(allowed.is_allowed());
assert!(!allowed.is_denied());
assert!(allowed.reason().is_none());
assert!(allowed.pack_id().is_none());
let denied = EvaluationResult::denied_by_pack("test.pack", "test reason", None);
assert!(!denied.is_allowed());
assert!(denied.is_denied());
assert_eq!(denied.reason(), Some("test reason"));
assert_eq!(denied.pack_id(), Some("test.pack"));
}
#[test]
fn test_denied_by_config() {
let denied = EvaluationResult::denied_by_config("config block".to_string());
assert!(denied.is_denied());
assert_eq!(denied.reason(), Some("config block"));
assert!(denied.pack_id().is_none());
assert_eq!(
denied.pattern_info.as_ref().unwrap().source,
MatchSource::ConfigOverride
);
}
#[test]
fn test_denied_by_legacy() {
let denied = EvaluationResult::denied_by_legacy("legacy reason");
assert!(denied.is_denied());
assert_eq!(denied.reason(), Some("legacy reason"));
assert!(denied.pack_id().is_none());
assert_eq!(
denied.pattern_info.as_ref().unwrap().source,
MatchSource::LegacyPattern
);
}
#[test]
fn test_denied_by_pack_pattern() {
let denied = EvaluationResult::denied_by_pack_pattern(
"core.git",
"reset-hard",
"test",
None,
crate::packs::Severity::Critical,
&[],
);
assert!(denied.is_denied());
assert_eq!(denied.pack_id(), Some("core.git"));
assert_eq!(
denied.pattern_info.as_ref().unwrap().pattern_name,
Some("reset-hard".to_string())
);
}
#[test]
fn test_quick_reject_skips_patterns() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let result = evaluate_command(
"cargo build --release",
&config,
&["git", "rm"],
&compiled,
&allowlists,
);
assert!(result.is_allowed());
let result = evaluate_command(
"npm install",
&config,
&["git", "rm", "docker", "kubectl"],
&compiled,
&allowlists,
);
assert!(result.is_allowed());
}
#[test]
fn heredoc_scan_runs_before_keyword_quick_reject() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd = r#"node -e "require('child_process').execSync('rm -rf /')"""#;
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(result.is_denied());
let info = result.pattern_info.expect("deny must include pattern info");
assert_eq!(info.source, MatchSource::HeredocAst);
assert!(
info.pack_id
.as_deref()
.is_some_and(|p| p.starts_with("heredoc."))
);
}
#[test]
fn heredoc_triggers_inside_safe_string_arguments_do_not_scan_or_block() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd =
r#"git commit -m "example: node -e \"require('child_process').execSync('rm -rf /')\"""#;
let result = evaluate_command(cmd, &config, &["git"], &compiled, &allowlists);
assert!(result.is_allowed());
}
#[test]
fn bd_notes_with_dangerous_text_is_allowed() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd = "bd create --notes This mentions rm -rf / but is just docs";
let result = evaluate_command(cmd, &config, &["rm"], &compiled, &allowlists);
assert!(result.is_allowed());
}
#[test]
fn bd_description_inline_code_is_blocked() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd = r#"bd create --description "$(rm -rf /)""#;
let result = evaluate_command(cmd, &config, &["rm"], &compiled, &allowlists);
assert!(result.is_denied());
}
#[test]
fn echo_with_dangerous_text_is_allowed() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd = r#"echo "rm -rf /""#;
let result = evaluate_command(cmd, &config, &["rm"], &compiled, &allowlists);
assert!(result.is_allowed());
}
#[test]
fn heredoc_commands_are_evaluated_and_block_when_severity_blocks_by_default() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd =
"node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(result.is_denied());
let info = result.pattern_info.expect("deny must include pattern info");
assert_eq!(info.source, MatchSource::HeredocAst);
assert_eq!(info.pack_id.as_deref(), Some("heredoc.javascript"));
assert!(
info.pattern_name
.as_deref()
.is_some_and(|p| p.starts_with("fs_rmsync")),
"expected a fs_rmsync* heredoc rule, got {:?}",
info.pattern_name
);
}
#[test]
fn heredoc_commands_with_non_blocking_matches_are_allowed() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd =
"node <<EOF\nconst fs = require('fs');\nfs.rmSync('./dist', { recursive: true });\nEOF";
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(result.is_allowed());
assert!(result.pattern_info.is_none());
}
#[test]
fn heredoc_scanning_can_be_disabled_via_config() {
let mut config = default_config();
config.heredoc.enabled = Some(false);
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd =
"node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(result.is_allowed());
assert!(result.pattern_info.is_none());
}
#[test]
fn heredoc_language_filter_can_skip_unwanted_languages() {
let mut config = default_config();
config.heredoc.languages = Some(vec!["python".to_string()]);
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd =
"node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(result.is_allowed());
assert!(result.pattern_info.is_none());
}
#[test]
fn heredoc_allowlist_can_override_ast_denial() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists =
project_allowlists_for_rule("heredoc.javascript:fs_rmsync.catastrophic", "local dev");
let cmd =
"node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(result.is_allowed());
let override_info = result
.allowlist_override
.as_ref()
.expect("allowlist override metadata must be present");
assert_eq!(override_info.layer, AllowlistLayer::Project);
assert_eq!(override_info.reason, "local dev");
assert_eq!(
override_info.matched.pack_id.as_deref(),
Some("heredoc.javascript")
);
assert_eq!(
override_info.matched.pattern_name.as_deref(),
Some("fs_rmsync.catastrophic")
);
assert_eq!(override_info.matched.source, MatchSource::HeredocAst);
}
#[test]
fn heredoc_content_allowlist_project_scope_skips_ast_scan() {
let mut config = default_config();
let cwd = std::env::current_dir().expect("current_dir must be available");
let cwd_str = cwd.to_string_lossy().into_owned();
config.heredoc.allowlist = Some(crate::config::HeredocAllowlistConfig {
projects: vec![crate::config::ProjectHeredocAllowlist {
path: cwd_str,
patterns: vec![crate::config::AllowedHeredocPattern {
language: Some("javascript".to_string()),
pattern: "fs.rmSync('/etc'".to_string(),
reason: "project allowlist".to_string(),
}],
content_hashes: vec![],
}],
..Default::default()
});
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let cmd =
"node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(
result.is_allowed(),
"project-scoped heredoc content allowlist should skip AST denial"
);
}
#[test]
fn heredoc_content_allowlist_project_scope_does_not_match_other_projects() {
let mut config = default_config();
config.heredoc.allowlist = Some(crate::config::HeredocAllowlistConfig {
projects: vec![crate::config::ProjectHeredocAllowlist {
path: "/definitely-not-a-prefix".to_string(),
patterns: vec![crate::config::AllowedHeredocPattern {
language: Some("javascript".to_string()),
pattern: "fs.rmSync('/etc'".to_string(),
reason: "wrong project".to_string(),
}],
content_hashes: vec![],
}],
..Default::default()
});
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let cmd =
"node <<EOF\nconst fs = require('fs');\nfs.rmSync('/etc', { recursive: true });\nEOF";
let result = evaluate_command(cmd, &config, &["kubectl"], &compiled, &allowlists);
assert!(
result.is_denied(),
"content allowlist should not apply when cwd is outside configured project scope"
);
}
#[test]
fn heredoc_trigger_strings_inside_safe_string_arguments_do_not_scan_or_block() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let cmd = r#"git commit -m "docs: example heredoc: cat <<EOF rm -rf / EOF""#;
let result = evaluate_command(cmd, &config, &["git"], &compiled, &allowlists);
assert!(result.is_allowed());
}
#[test]
fn test_evaluation_decision_equality() {
assert_eq!(EvaluationDecision::Allow, EvaluationDecision::Allow);
assert_eq!(EvaluationDecision::Deny, EvaluationDecision::Deny);
assert_ne!(EvaluationDecision::Allow, EvaluationDecision::Deny);
}
#[test]
fn test_match_source_equality() {
assert_eq!(MatchSource::ConfigOverride, MatchSource::ConfigOverride);
assert_eq!(MatchSource::LegacyPattern, MatchSource::LegacyPattern);
assert_eq!(MatchSource::Pack, MatchSource::Pack);
assert_eq!(MatchSource::HeredocAst, MatchSource::HeredocAst);
assert_ne!(MatchSource::ConfigOverride, MatchSource::Pack);
}
#[test]
fn allowlist_hit_overrides_deny() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = project_allowlists_for_rule("core.git:reset-hard", "local dev flow");
let result = evaluate_command(
"git reset --hard",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(result.is_allowed());
assert!(result.allowlist_override.is_some());
}
#[test]
fn allowlist_miss_does_not_change_decision() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = project_allowlists_for_rule("core.git:reset-merge", "not this one");
let result = evaluate_command(
"git reset --hard",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(result.is_denied());
assert!(result.allowlist_override.is_none());
assert_eq!(result.pack_id(), Some("core.git"));
}
#[test]
fn wildcard_allowlist_matches_only_within_pack() {
let mut config = default_config();
config.packs.enabled.push("strict_git".to_string());
let compiled = config.overrides.compile();
let allowlists = project_allowlists_for_pack_wildcard("core.git", "allow all core.git");
let git_result = evaluate_command(
"git reset --hard",
&config,
&["git", "rm"],
&compiled,
&allowlists,
);
assert!(git_result.is_allowed());
assert!(git_result.allowlist_override.is_some());
let rm_result = evaluate_command(
"rm -rf /etc",
&config,
&["git", "rm"],
&compiled,
&allowlists,
);
assert!(rm_result.is_denied());
assert_eq!(rm_result.pack_id(), Some("core.filesystem"));
}
#[test]
fn allowlisting_one_rule_does_not_disable_other_packs() {
let mut config = default_config();
config.packs.enabled.push("strict_git".to_string());
let compiled = config.overrides.compile();
let allowlists =
project_allowlists_for_rule("core.git:push-force-long", "allow core force");
let result = evaluate_command(
"git push origin main --force",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(result.is_denied());
assert_eq!(result.pack_id(), Some("strict_git"));
assert_eq!(
result
.pattern_info
.as_ref()
.unwrap()
.pattern_name
.as_deref(),
Some("push-force-any") );
}
#[test]
fn evaluator_allows_safe_commands() {
let config = default_config();
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
let keywords = &["git", "rm", "docker", "kubectl"];
let test_cases = [
"ls -la",
"cargo build --release",
"npm install",
"echo hello",
"cat /etc/passwd",
"",
];
for cmd in test_cases {
let result = evaluate_command(cmd, &config, keywords, &compiled, &allowlists);
assert!(
result.is_allowed(),
"Expected ALLOWED for {cmd:?}, got DENIED"
);
}
}
#[test]
fn evaluator_respects_config_allow_override() {
let config = default_config();
let compiled = default_compiled_overrides();
let tmp = std::env::temp_dir();
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = tmp.join(format!(
"dcg_allowlist_test_{}_{}.toml",
std::process::id(),
unique
));
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "integration test"
"#;
std::fs::write(&path, toml).expect("write allowlist file");
let allowlists = LayeredAllowlist::load_from_paths(Some(path), None, None);
let result = evaluate_command(
"git reset --hard",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(result.is_allowed());
assert!(result.allowlist_override.is_some());
}
#[test]
fn config_block_override_wins_over_overlapping_allow_in_main_path() {
let mut config = default_config();
config.overrides.allow = vec![crate::config::AllowOverride::Simple(
r"\bgit\s+reset\s+--hard\b".to_string(),
)];
config.overrides.block = vec![crate::config::BlockOverride {
pattern: r"\bgit\s+reset\s+--hard\b".to_string(),
reason: "explicit config block".to_string(),
}];
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let result = evaluate_command(
"git reset --hard",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(result.is_denied());
assert_eq!(result.reason(), Some("explicit config block"));
assert_eq!(
result.pattern_info.as_ref().unwrap().source,
MatchSource::ConfigOverride
);
}
#[test]
fn config_block_override_wins_over_overlapping_allow_in_legacy_path() {
let mut config = default_config();
config.overrides.allow = vec![crate::config::AllowOverride::Simple(
r"\bgit\s+reset\s+--hard\b".to_string(),
)];
config.overrides.block = vec![crate::config::BlockOverride {
pattern: r"\bgit\s+reset\s+--hard\b".to_string(),
reason: "explicit config block".to_string(),
}];
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let result = evaluate_command_with_legacy::<
crate::packs::SafePattern,
crate::packs::DestructivePattern,
>(
"git reset --hard",
&config,
&["git"],
&compiled,
&allowlists,
&[],
&[],
);
assert!(result.is_denied());
assert_eq!(result.reason(), Some("explicit config block"));
assert_eq!(
result.pattern_info.as_ref().unwrap().source,
MatchSource::ConfigOverride
);
}
#[test]
fn truncate_preview_handles_utf8_safely() {
let short = "hello";
assert_eq!(super::truncate_preview(short, 10), "hello");
let exact = "hello";
assert_eq!(super::truncate_preview(exact, 5), "hello");
let long = "hello world";
assert_eq!(super::truncate_preview(long, 8), "hello...");
let japanese = "こんにちは世界"; let truncated = super::truncate_preview(japanese, 5);
assert!(truncated.ends_with("..."));
assert_eq!(truncated, "こん...");
let emoji = "🔥🔥🔥🔥🔥"; let truncated_emoji = super::truncate_preview(emoji, 3);
assert_eq!(truncated_emoji, "..."); }
#[test]
fn extract_match_preview_bounds_check() {
let cmd = "rm -rf /important";
let span = super::MatchSpan { start: 0, end: 2 };
assert_eq!(super::extract_match_preview(cmd, &span), "rm");
let span_end = super::MatchSpan { start: 7, end: 17 };
assert_eq!(super::extract_match_preview(cmd, &span_end), "/important");
let span_overflow = super::MatchSpan {
start: 0,
end: 1000,
};
assert_eq!(
super::extract_match_preview(cmd, &span_overflow),
"rm -rf /important"
);
let span_invalid = super::MatchSpan {
start: 100,
end: 50,
};
assert_eq!(super::extract_match_preview(cmd, &span_invalid), "");
}
#[test]
fn extract_match_preview_handles_invalid_utf8_boundaries() {
let cmd = "日本語";
let valid_span = super::MatchSpan { start: 0, end: 3 };
assert_eq!(super::extract_match_preview(cmd, &valid_span), "日");
let invalid_start = super::MatchSpan { start: 1, end: 6 };
assert_eq!(super::extract_match_preview(cmd, &invalid_start), "本");
let invalid_end = super::MatchSpan { start: 0, end: 4 };
assert_eq!(super::extract_match_preview(cmd, &invalid_end), "日");
let both_invalid = super::MatchSpan { start: 1, end: 4 };
assert_eq!(super::extract_match_preview(cmd, &both_invalid), "");
let within_char = super::MatchSpan { start: 1, end: 2 };
assert_eq!(super::extract_match_preview(cmd, &within_char), "");
}
#[test]
fn heredoc_matches_include_span_info() {
let mut config = default_config();
config.packs.enabled.push("system.core".to_string());
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let enabled_packs = config.enabled_pack_ids();
let keywords_vec = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
let keywords: Vec<&str> = keywords_vec.clone();
let cmd = "cat <<'EOF'\nrm -rf /\nEOF";
let result = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
if result.is_denied() {
if let Some(ref pattern_info) = result.pattern_info {
if let Some(span) = pattern_info.matched_span {
assert!(span.start <= span.end, "Span start should not exceed end");
assert!(
span.end <= cmd.len(),
"Span end should not exceed command length"
);
let matched = cmd.get(span.start..span.end).unwrap_or("");
assert!(
matched.contains("rm -rf /"),
"Matched span should point into heredoc content"
);
}
}
}
}
#[test]
fn match_span_maps_to_original_with_wrappers() {
let mut config = default_config();
config.packs.enabled.push("core.git".to_string());
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let enabled_packs = config.enabled_pack_ids();
let keywords_vec = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
let keywords: Vec<&str> = keywords_vec.clone();
let cmd = "sudo git reset --hard";
let result = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
assert!(result.is_denied(), "Command should be denied");
let pattern_info = result.pattern_info.expect("Expected pattern info");
let span = pattern_info.matched_span.expect("Expected matched span");
let matched = cmd.get(span.start..span.end).unwrap_or("");
assert_eq!(matched, "git reset --hard");
}
#[test]
fn match_span_determinism() {
let mut config = default_config();
config.packs.enabled.push("system.core".to_string());
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let enabled_packs = config.enabled_pack_ids();
let keywords_vec = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
let keywords: Vec<&str> = keywords_vec.clone();
let cmd = "rm -rf /";
let result1 = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
let result2 = evaluate_command(cmd, &config, &keywords, &compiled, &allowlists);
assert_eq!(result1.decision, result2.decision);
assert_eq!(
result1.pattern_info.as_ref().map(|p| p.matched_span),
result2.pattern_info.as_ref().map(|p| p.matched_span),
"Match span should be deterministic"
);
assert_eq!(
result1
.pattern_info
.as_ref()
.map(|p| p.matched_text_preview.as_ref()),
result2
.pattern_info
.as_ref()
.map(|p| p.matched_text_preview.as_ref()),
"Match text preview should be deterministic"
);
}
mod deadline_tests {
use super::*;
use crate::perf::Deadline;
use std::time::Duration;
fn test_heredoc_settings() -> crate::config::HeredocSettings {
crate::config::Config::default().heredoc_settings()
}
#[test]
fn exceeded_deadline_fails_open() {
let compiled_overrides = default_compiled_overrides();
let allowlists = default_allowlists();
let heredoc_settings = test_heredoc_settings();
let enabled_keywords: Vec<&str> = vec!["git", "rm"];
let ordered_packs: Vec<String> = vec!["core.git".to_string()];
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let deadline = Deadline::new(Duration::ZERO);
let result = evaluate_command_with_pack_order_deadline(
"git reset --hard",
&enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
&compiled_overrides,
&allowlists,
&heredoc_settings,
None,
Some(&deadline),
);
assert!(
result.is_allowed(),
"Zero-duration deadline should fail open and allow command"
);
assert!(
result.skipped_due_to_budget,
"Result should indicate it was skipped due to budget"
);
}
#[test]
fn normal_deadline_allows_evaluation() {
let compiled_overrides = default_compiled_overrides();
let allowlists = default_allowlists();
let heredoc_settings = test_heredoc_settings();
let enabled_keywords: Vec<&str> = vec!["git", "rm"];
let ordered_packs: Vec<String> = vec!["core.git".to_string()];
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let deadline = Deadline::new(Duration::from_secs(10));
let result = evaluate_command_with_pack_order_deadline(
"git reset --hard",
&enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
&compiled_overrides,
&allowlists,
&heredoc_settings,
None,
Some(&deadline),
);
assert!(
result.is_denied(),
"Normal deadline should allow evaluation to proceed and deny destructive command"
);
assert!(
!result.skipped_due_to_budget,
"Result should not indicate budget skip"
);
}
#[test]
fn no_deadline_allows_evaluation() {
let compiled_overrides = default_compiled_overrides();
let allowlists = default_allowlists();
let heredoc_settings = test_heredoc_settings();
let enabled_keywords: Vec<&str> = vec!["git", "rm"];
let ordered_packs: Vec<String> = vec!["core.git".to_string()];
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let result = evaluate_command_with_pack_order_deadline(
"git reset --hard",
&enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
&compiled_overrides,
&allowlists,
&heredoc_settings,
None,
None, );
assert!(
result.is_denied(),
"No deadline should allow evaluation to proceed and deny destructive command"
);
assert!(
!result.skipped_due_to_budget,
"Result should not indicate budget skip"
);
}
#[test]
fn safe_command_with_deadline() {
let compiled_overrides = default_compiled_overrides();
let allowlists = default_allowlists();
let heredoc_settings = test_heredoc_settings();
let enabled_keywords: Vec<&str> = vec!["git", "rm"];
let ordered_packs: Vec<String> = vec!["core.git".to_string()];
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let deadline = Deadline::new(Duration::from_secs(10));
let result = evaluate_command_with_pack_order_deadline(
"git status",
&enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
&compiled_overrides,
&allowlists,
&heredoc_settings,
None,
Some(&deadline),
);
assert!(result.is_allowed(), "Safe command should be allowed");
assert!(
!result.skipped_due_to_budget,
"Safe command should not trigger budget skip"
);
}
#[test]
fn allowed_due_to_budget_structure() {
let result = EvaluationResult::allowed_due_to_budget();
assert!(result.is_allowed());
assert!(!result.is_denied());
assert!(result.skipped_due_to_budget);
assert!(result.pattern_info.is_none());
assert!(result.allowlist_override.is_none());
assert!(result.effective_mode.is_none());
}
#[test]
fn deadline_enforced_during_safe_pattern_matching() {
use crate::packs::Pack;
let mut safe_patterns = Vec::new();
for i in 0..20 {
safe_patterns.push(crate::packs::SafePattern {
regex: crate::packs::regex_engine::LazyCompiledRegex::new(
if i % 2 == 0 {
r"(?=.*safe_cmd)(\w+\s+)*\w+"
} else {
r"(?=.*no_match_ever)(\w+\s+)*\w+"
},
),
name: "adversarial_safe",
});
}
let pack = Pack {
id: "test.adversarial".to_string(),
name: "adversarial",
description: "test pack",
keywords: &["rm"],
safe_patterns,
destructive_patterns: vec![crate::destructive_pattern!(
"adversarial_rm",
r"rm\b",
"test destructive",
High
)],
keyword_matcher: None,
safe_regex_set: None,
safe_regex_set_is_complete: false,
};
let adversarial = format!("rm {}", "a ".repeat(30));
let deadline = Deadline::new(Duration::ZERO);
let result = pack.matches_safe_with_deadline(&adversarial, Some(&deadline));
assert!(
!result,
"Should bail out (return false) when deadline exceeded during safe pattern scan"
);
}
#[test]
fn deadline_enforced_after_destructive_regex_find() {
let compiled_overrides = default_compiled_overrides();
let allowlists = default_allowlists();
let heredoc_settings = test_heredoc_settings();
let enabled_keywords: Vec<&str> = vec!["rm"];
let ordered_packs: Vec<String> = vec!["core.filesystem".to_string()];
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let deadline = Deadline::new(Duration::ZERO);
std::thread::sleep(Duration::from_millis(1));
let result = evaluate_command_with_pack_order_deadline(
"rm -rf /important",
&enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
&compiled_overrides,
&allowlists,
&heredoc_settings,
None,
Some(&deadline),
);
assert!(result.is_allowed());
assert!(result.skipped_due_to_budget);
}
#[test]
fn generous_deadline_still_denies_destructive() {
let compiled_overrides = default_compiled_overrides();
let allowlists = default_allowlists();
let heredoc_settings = test_heredoc_settings();
let enabled_keywords: Vec<&str> = vec!["git"];
let ordered_packs: Vec<String> = vec!["core.git".to_string()];
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let deadline = Deadline::new(Duration::from_secs(30));
let result = evaluate_command_with_pack_order_deadline(
"git reset --hard HEAD~5",
&enabled_keywords,
&ordered_packs,
keyword_index.as_ref(),
&compiled_overrides,
&allowlists,
&heredoc_settings,
None,
Some(&deadline),
);
assert!(
result.is_denied(),
"Generous deadline should still deny destructive commands"
);
assert!(!result.skipped_due_to_budget);
}
}
#[test]
fn integration_allowlist_file_overrides_deny() {
let config = default_config();
let compiled = default_compiled_overrides();
let tmp = std::env::temp_dir();
let unique = COUNTER.fetch_add(1, Ordering::Relaxed);
let path = tmp.join(format!(
"dcg_allowlist_test_{}_{}.toml",
std::process::id(),
unique
));
let toml = r#"
[[allow]]
rule = "core.git:reset-hard"
reason = "integration test"
"#;
std::fs::write(&path, toml).expect("write allowlist file");
let allowlists = LayeredAllowlist::load_from_paths(Some(path), None, None);
let result = evaluate_command(
"git reset --hard",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(result.is_allowed());
assert!(result.allowlist_override.is_some());
}
#[test]
fn medium_severity_patterns_are_evaluated() {
let mut config = default_config();
config.packs.enabled.push("containers.docker".to_string());
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let result = evaluate_command(
"docker image prune",
&config,
&["docker"],
&compiled,
&allowlists,
);
assert!(
result.is_denied(),
"Medium severity pattern should be evaluated and return Deny"
);
let info = result
.pattern_info
.as_ref()
.expect("should have pattern info");
assert_eq!(
info.severity,
Some(crate::packs::Severity::Medium),
"Pattern should have Medium severity"
);
assert_eq!(info.pack_id.as_deref(), Some("containers.docker"));
assert_eq!(info.pattern_name.as_deref(), Some("image-prune"));
}
#[test]
fn medium_severity_git_patterns_are_evaluated() {
let config = default_config();
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let branch_result = evaluate_command(
"git branch -D feature-branch",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(
branch_result.is_denied(),
"git branch -D should be evaluated"
);
let branch_info = branch_result.pattern_info.as_ref().unwrap();
assert_eq!(branch_info.severity, Some(crate::packs::Severity::Medium));
assert_eq!(
branch_info.pattern_name.as_deref(),
Some("branch-force-delete")
);
let stash_result = evaluate_command(
"git stash drop stash@{0}",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(
stash_result.is_denied(),
"git stash drop should be evaluated"
);
let stash_info = stash_result.pattern_info.as_ref().unwrap();
assert_eq!(stash_info.severity, Some(crate::packs::Severity::Medium));
assert_eq!(stash_info.pattern_name.as_deref(), Some("stash-drop"));
}
#[test]
fn critical_patterns_still_return_critical_severity() {
let config = default_config();
let compiled = config.overrides.compile();
let allowlists = default_allowlists();
let result = evaluate_command(
"git reset --hard",
&config,
&["git"],
&compiled,
&allowlists,
);
assert!(result.is_denied());
let info = result.pattern_info.as_ref().unwrap();
assert_eq!(
info.severity,
Some(crate::packs::Severity::Critical),
"git reset --hard should remain Critical severity"
);
let clear_result =
evaluate_command("git stash clear", &config, &["git"], &compiled, &allowlists);
assert!(clear_result.is_denied());
let clear_info = clear_result.pattern_info.as_ref().unwrap();
assert_eq!(
clear_info.severity,
Some(crate::packs::Severity::Critical),
"git stash clear should remain Critical severity"
);
}
#[test]
fn policy_converts_medium_to_warn_mode() {
let policy = crate::config::PolicyConfig::default();
let mode = policy.resolve_mode(
Some("containers.docker"),
Some("image-prune"),
Some(crate::packs::Severity::Medium),
);
assert_eq!(
mode,
crate::packs::DecisionMode::Warn,
"Medium severity should default to Warn mode"
);
let critical_mode = policy.resolve_mode(
Some("core.git"),
Some("reset-hard"),
Some(crate::packs::Severity::Critical),
);
assert_eq!(
critical_mode,
crate::packs::DecisionMode::Deny,
"Critical severity should always be Deny mode"
);
}
#[test]
fn window_command_short_command_unchanged() {
let cmd = "git reset --hard";
let span = MatchSpan { start: 0, end: 16 };
let result = window_command(cmd, &span, 80);
assert_eq!(result.display, cmd);
assert!(result.adjusted_span.is_some());
let adj = result.adjusted_span.unwrap();
assert_eq!(adj.start, 0);
assert_eq!(adj.end, 16);
}
#[test]
fn window_command_long_command_with_ellipsis() {
let prefix = "a".repeat(50);
let suffix = "b".repeat(50);
let match_text = "git reset --hard";
let cmd = format!("{prefix}{match_text}{suffix}");
let span = MatchSpan {
start: 50,
end: 50 + 16,
};
let result = window_command(&cmd, &span, 40);
assert!(result.display.starts_with("..."));
assert!(result.display.ends_with("..."));
assert!(result.display.contains("git reset --hard"));
let adj = result.adjusted_span.expect("Should have adjusted span");
let windowed_match: String = result
.display
.chars()
.skip(adj.start)
.take(adj.end - adj.start)
.collect();
assert_eq!(windowed_match, "git reset --hard");
}
#[test]
fn window_command_match_at_start() {
let match_text = "rm -rf /";
let suffix = "x".repeat(100);
let cmd = format!("{match_text}{suffix}");
let span = MatchSpan { start: 0, end: 8 };
let result = window_command(&cmd, &span, 40);
assert!(!result.display.starts_with("..."));
assert!(result.display.ends_with("..."));
assert!(result.display.contains("rm -rf /"));
let adj = result.adjusted_span.expect("Should have adjusted span");
assert_eq!(adj.start, 0);
}
#[test]
fn window_command_match_at_end() {
let prefix = "y".repeat(100);
let match_text = "rm -rf /";
let cmd = format!("{prefix}{match_text}");
let span = MatchSpan {
start: 100,
end: 108,
};
let result = window_command(&cmd, &span, 40);
assert!(result.display.starts_with("..."));
assert!(!result.display.ends_with("..."));
assert!(result.display.contains("rm -rf /"));
}
#[test]
fn window_command_utf8_multibyte_chars() {
let cmd = "echo 🎉🎊🎈 && rm -rf / && echo done";
let span = MatchSpan { start: 21, end: 29 };
let result = window_command(cmd, &span, 50);
assert!(result.display.contains("rm -rf /"));
assert!(result.adjusted_span.is_some());
}
#[test]
fn window_command_invalid_span_handles_gracefully() {
let cmd = "short";
let span = MatchSpan {
start: 100,
end: 200,
};
let result = window_command(cmd, &span, 80);
assert_eq!(result.display, "short");
assert!(result.adjusted_span.is_none());
}
mod branch_strictness_tests {
use super::*;
use crate::config::{GitAwarenessConfig, StrictnessLevel};
use crate::packs::Severity;
use std::path::Path;
use std::process::Command;
fn config_with_git_awareness(enabled: bool) -> Config {
let mut config = Config::default();
config.git_awareness.enabled = enabled;
config
}
fn create_deny_result_with_severity(severity: Severity) -> EvaluationResult {
EvaluationResult {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
pack_id: Some("test.pack".to_string()),
pattern_name: Some("test_pattern".to_string()),
severity: Some(severity),
reason: "Test reason".to_string(),
source: MatchSource::Pack,
matched_span: None,
matched_text_preview: None,
explanation: None,
suggestions: &[],
}),
allowlist_override: None,
effective_mode: Some(crate::packs::DecisionMode::Deny),
skipped_due_to_budget: false,
branch_context: None,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
}
}
fn run_git(repo_path: &Path, args: &[&str]) {
let output = Command::new("git")
.current_dir(repo_path)
.args(args)
.output()
.expect("failed to run git command");
assert!(
output.status.success(),
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&output.stderr)
);
}
fn init_git_repo(repo_path: &Path, branch: &str) {
run_git(repo_path, &["init"]);
run_git(
repo_path,
&["config", "user.email", "dcg-tests@example.com"],
);
run_git(repo_path, &["config", "user.name", "DCG Tests"]);
run_git(repo_path, &["checkout", "-b", branch]);
}
fn init_git_repo_detached(repo_path: &Path) {
init_git_repo(repo_path, "main");
std::fs::write(repo_path.join("seed"), "seed").expect("seed file");
run_git(repo_path, &["add", "seed"]);
run_git(repo_path, &["commit", "-m", "seed"]);
run_git(repo_path, &["checkout", "--detach", "HEAD"]);
}
#[test]
fn disabled_git_awareness_returns_unchanged_result() {
let config = config_with_git_awareness(false);
let result = create_deny_result_with_severity(Severity::High);
let modified = apply_branch_strictness(result, &config, None);
assert_eq!(modified.decision, EvaluationDecision::Deny);
assert!(modified.branch_context.is_none());
}
#[test]
fn strictness_level_should_block_checks_critical() {
assert!(StrictnessLevel::Critical.should_block(Severity::Critical));
assert!(!StrictnessLevel::Critical.should_block(Severity::High));
assert!(!StrictnessLevel::Critical.should_block(Severity::Medium));
assert!(!StrictnessLevel::Critical.should_block(Severity::Low));
}
#[test]
fn strictness_level_should_block_checks_high() {
assert!(StrictnessLevel::High.should_block(Severity::Critical));
assert!(StrictnessLevel::High.should_block(Severity::High));
assert!(!StrictnessLevel::High.should_block(Severity::Medium));
assert!(!StrictnessLevel::High.should_block(Severity::Low));
}
#[test]
fn strictness_level_should_block_checks_medium() {
assert!(StrictnessLevel::Medium.should_block(Severity::Critical));
assert!(StrictnessLevel::Medium.should_block(Severity::High));
assert!(StrictnessLevel::Medium.should_block(Severity::Medium));
assert!(!StrictnessLevel::Medium.should_block(Severity::Low));
}
#[test]
fn strictness_level_should_block_checks_all() {
assert!(StrictnessLevel::All.should_block(Severity::Critical));
assert!(StrictnessLevel::All.should_block(Severity::High));
assert!(StrictnessLevel::All.should_block(Severity::Medium));
assert!(StrictnessLevel::All.should_block(Severity::Low));
}
#[test]
fn git_awareness_config_is_protected_branch() {
let config = GitAwarenessConfig {
enabled: true,
protected_branches: vec!["main".to_string(), "master".to_string()],
protected_strictness: StrictnessLevel::All,
relaxed_branches: vec![],
relaxed_strictness: StrictnessLevel::Critical,
default_strictness: StrictnessLevel::High,
detached_head_strictness: StrictnessLevel::All,
relaxed_disabled_packs: vec![],
show_branch_in_output: true,
warn_if_not_git: false,
};
assert!(config.is_protected_branch(Some("main")));
assert!(config.is_protected_branch(Some("master")));
assert!(!config.is_protected_branch(Some("feature/test")));
assert!(!config.is_protected_branch(None));
}
#[test]
fn git_awareness_config_is_relaxed_branch_with_glob() {
let config = GitAwarenessConfig {
enabled: true,
protected_branches: vec![],
protected_strictness: StrictnessLevel::All,
relaxed_branches: vec!["feature/*".to_string(), "experiment/*".to_string()],
relaxed_strictness: StrictnessLevel::Critical,
default_strictness: StrictnessLevel::High,
detached_head_strictness: StrictnessLevel::All,
relaxed_disabled_packs: vec![],
show_branch_in_output: true,
warn_if_not_git: false,
};
assert!(config.is_relaxed_branch(Some("feature/my-feature")));
assert!(config.is_relaxed_branch(Some("experiment/test")));
assert!(!config.is_relaxed_branch(Some("main")));
assert!(!config.is_relaxed_branch(None));
}
#[test]
fn git_awareness_config_strictness_for_branch() {
let config = GitAwarenessConfig {
enabled: true,
protected_branches: vec!["main".to_string()],
protected_strictness: StrictnessLevel::All,
relaxed_branches: vec!["feature/*".to_string()],
relaxed_strictness: StrictnessLevel::Critical,
default_strictness: StrictnessLevel::High,
detached_head_strictness: StrictnessLevel::All,
relaxed_disabled_packs: vec![],
show_branch_in_output: true,
warn_if_not_git: false,
};
assert_eq!(
config.strictness_for_branch(Some("main")),
StrictnessLevel::All
);
assert_eq!(
config.strictness_for_branch(Some("feature/test")),
StrictnessLevel::Critical
);
assert_eq!(
config.strictness_for_branch(Some("develop")),
StrictnessLevel::High
);
assert_eq!(config.strictness_for_branch(None), StrictnessLevel::High);
}
#[test]
fn git_awareness_not_in_repo_uses_default_strictness() {
let mut config = Config::default();
config.git_awareness.enabled = true;
config.git_awareness.warn_if_not_git = false;
let result = EvaluationResult {
decision: EvaluationDecision::Deny,
pattern_info: Some(PatternMatch {
reason: "test reason".to_string(),
pattern_name: Some("test-pattern".to_string()),
pack_id: Some("test.pack".to_string()),
severity: Some(crate::packs::Severity::High),
source: MatchSource::Pack,
matched_span: None,
matched_text_preview: None,
explanation: None,
suggestions: &[],
}),
allowlist_override: None,
branch_context: None,
effective_mode: None,
skipped_due_to_budget: false,
session_occurrence: None,
graduated_response: None,
bypass_method: None,
};
let temp_dir = std::env::temp_dir();
let unique_dir = temp_dir.join(format!("dcg_test_{}", std::process::id()));
let _ = std::fs::create_dir_all(&unique_dir);
let modified_result =
apply_branch_strictness(result.clone(), &config, Some(unique_dir.as_path()));
assert_eq!(modified_result.decision, result.decision);
assert!(
modified_result.branch_context.is_none(),
"Branch context should be None when not in a git repo"
);
let _ = std::fs::remove_dir(&unique_dir);
}
#[test]
fn git_awareness_warn_if_not_git_config() {
let mut config = Config::default();
assert!(
!config.git_awareness.warn_if_not_git,
"warn_if_not_git should default to false"
);
config.git_awareness.warn_if_not_git = true;
assert!(config.git_awareness.warn_if_not_git);
}
#[test]
fn relaxed_branch_can_downgrade_deny_to_allow() {
let temp = tempfile::tempdir().expect("tempdir");
init_git_repo(temp.path(), "feature/relaxed");
let mut config = Config::default();
config.git_awareness.enabled = true;
config.git_awareness.protected_branches = vec!["main".to_string()];
config.git_awareness.protected_strictness = StrictnessLevel::All;
config.git_awareness.relaxed_branches = vec!["feature/*".to_string()];
config.git_awareness.relaxed_strictness = StrictnessLevel::Critical;
config.git_awareness.default_strictness = StrictnessLevel::High;
config.git_awareness.warn_if_not_git = false;
let result = create_deny_result_with_severity(Severity::Low);
let modified = apply_branch_strictness(result, &config, Some(temp.path()));
assert_eq!(modified.decision, EvaluationDecision::Allow);
let branch_context = modified
.branch_context
.expect("branch context should be populated");
assert_eq!(
branch_context.branch_name.as_deref(),
Some("feature/relaxed")
);
assert!(!branch_context.is_protected);
assert!(branch_context.is_relaxed);
assert_eq!(branch_context.strictness, StrictnessLevel::Critical);
assert!(branch_context.affected_decision);
}
#[test]
fn protected_branch_keeps_deny_for_blocked_severity() {
let temp = tempfile::tempdir().expect("tempdir");
init_git_repo(temp.path(), "main");
let mut config = Config::default();
config.git_awareness.enabled = true;
config.git_awareness.protected_branches = vec!["main".to_string()];
config.git_awareness.protected_strictness = StrictnessLevel::All;
config.git_awareness.relaxed_branches = vec!["feature/*".to_string()];
config.git_awareness.relaxed_strictness = StrictnessLevel::Critical;
config.git_awareness.default_strictness = StrictnessLevel::High;
config.git_awareness.warn_if_not_git = false;
let result = create_deny_result_with_severity(Severity::High);
let modified = apply_branch_strictness(result, &config, Some(temp.path()));
assert_eq!(modified.decision, EvaluationDecision::Deny);
let branch_context = modified
.branch_context
.expect("branch context should be populated");
assert_eq!(branch_context.branch_name.as_deref(), Some("main"));
assert!(branch_context.is_protected);
assert!(!branch_context.is_relaxed);
assert_eq!(branch_context.strictness, StrictnessLevel::All);
assert!(!branch_context.affected_decision);
}
#[test]
fn detached_head_uses_detached_head_strictness_not_default() {
let temp = tempfile::tempdir().expect("tempdir");
init_git_repo_detached(temp.path());
let mut config = Config::default();
config.git_awareness.enabled = true;
config.git_awareness.protected_branches = vec!["main".to_string()];
config.git_awareness.protected_strictness = StrictnessLevel::All;
config.git_awareness.relaxed_branches = vec!["feature/*".to_string()];
config.git_awareness.relaxed_strictness = StrictnessLevel::Critical;
config.git_awareness.default_strictness = StrictnessLevel::Critical;
config.git_awareness.detached_head_strictness = StrictnessLevel::All;
config.git_awareness.warn_if_not_git = false;
let result = create_deny_result_with_severity(Severity::Low);
let modified = apply_branch_strictness(result, &config, Some(temp.path()));
assert_eq!(modified.decision, EvaluationDecision::Deny);
let branch_context = modified
.branch_context
.expect("branch context should be populated");
assert!(branch_context.branch_name.is_none());
assert!(!branch_context.is_protected);
assert!(!branch_context.is_relaxed);
assert_eq!(branch_context.strictness, StrictnessLevel::All);
}
#[test]
fn detached_head_can_be_set_to_default_strictness() {
let temp = tempfile::tempdir().expect("tempdir");
init_git_repo_detached(temp.path());
let mut config = Config::default();
config.git_awareness.enabled = true;
config.git_awareness.default_strictness = StrictnessLevel::Critical;
config.git_awareness.detached_head_strictness = StrictnessLevel::Critical;
config.git_awareness.warn_if_not_git = false;
let result = create_deny_result_with_severity(Severity::Low);
let modified = apply_branch_strictness(result, &config, Some(temp.path()));
assert_eq!(modified.decision, EvaluationDecision::Allow);
let branch_context = modified
.branch_context
.expect("branch context should be populated");
assert_eq!(branch_context.strictness, StrictnessLevel::Critical);
assert!(branch_context.affected_decision);
}
#[test]
fn detached_head_strictness_defaults_to_all() {
let cfg = Config::default();
assert_eq!(
cfg.git_awareness.detached_head_strictness,
StrictnessLevel::All,
"detached HEAD must default to the strictest level"
);
}
}
mod heredoc_fail_open {
use super::*;
fn heredoc_config(
fallback_on_parse_error: bool,
fallback_on_timeout: bool,
) -> crate::config::HeredocSettings {
crate::config::HeredocSettings {
enabled: true,
fallback_on_parse_error,
fallback_on_timeout,
limits: crate::heredoc::ExtractionLimits::default(),
allowed_languages: None,
content_allowlist: None,
}
}
fn heredoc_config_with_limits(
limits: crate::heredoc::ExtractionLimits,
) -> crate::config::HeredocSettings {
crate::config::HeredocSettings {
enabled: true,
fallback_on_parse_error: true,
fallback_on_timeout: true,
limits,
allowed_languages: None,
content_allowlist: None,
}
}
fn eval_with_heredoc(
command: &str,
settings: &crate::config::HeredocSettings,
) -> EvaluationResult {
let config = default_config();
let enabled_packs = config.enabled_pack_ids();
let ordered_packs = crate::packs::REGISTRY.expand_enabled_ordered(&enabled_packs);
let enabled_keywords = crate::packs::REGISTRY.collect_enabled_keywords(&enabled_packs);
let keyword_index = crate::packs::REGISTRY.build_enabled_keyword_index(&ordered_packs);
let compiled = default_compiled_overrides();
let allowlists = default_allowlists();
evaluate_command_with_pack_order(
command,
enabled_keywords.as_slice(),
ordered_packs.as_slice(),
keyword_index.as_ref(),
&compiled,
&allowlists,
settings,
)
}
#[test]
fn unterminated_heredoc_allows_in_failopen_mode() {
let settings = heredoc_config(true, true);
let cmd = "python3 -c 'import shutil' << EOF\nsome content without closing";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_allowed(),
"unterminated heredoc should fail-open when fallback_on_parse_error=true"
);
}
#[test]
fn exceeded_size_limit_allows_in_failopen_mode() {
let limits = crate::heredoc::ExtractionLimits {
max_body_bytes: 10,
max_body_lines: 10_000,
max_heredocs: 10,
timeout_ms: 50,
};
let settings = heredoc_config_with_limits(limits);
let cmd = "bash -c 'echo test' << EOF\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nEOF";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_allowed(),
"exceeded size limit should fail-open with default settings"
);
}
#[test]
fn exceeded_line_limit_allows_in_failopen_mode() {
let limits = crate::heredoc::ExtractionLimits {
max_body_bytes: 1024 * 1024,
max_body_lines: 1,
max_heredocs: 10,
timeout_ms: 50,
};
let settings = heredoc_config_with_limits(limits);
let cmd = "bash -c 'echo test' << EOF\nline1\nline2\nline3\nEOF";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_allowed(),
"exceeded line limit should fail-open with default settings"
);
}
#[test]
fn exceeded_heredoc_limit_allows_in_failopen_mode() {
let limits = crate::heredoc::ExtractionLimits {
max_body_bytes: 1024 * 1024,
max_body_lines: 10_000,
max_heredocs: 0,
timeout_ms: 50,
};
let settings = heredoc_config_with_limits(limits);
let cmd = "bash -c 'echo test' << EOF\ncontent\nEOF";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_allowed(),
"exceeded heredoc limit should fail-open with default settings"
);
}
#[test]
fn binary_content_allows_in_failopen_mode() {
let settings = heredoc_config(true, true);
let cmd = "python3 -c '\x00\x01\x02\x03\x04\x05\x06\x07'";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_allowed(),
"binary content should fail-open with default settings"
);
}
#[test]
fn strict_parse_error_denies_on_unterminated_heredoc() {
let settings = heredoc_config(false, true);
let cmd = "cat << EOF\ncontent without closing delimiter";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_denied(),
"unterminated heredoc should deny when fallback_on_parse_error=false, \
got: {result:?}"
);
}
#[test]
fn strict_parse_error_denies_on_exceeded_size() {
let mut settings = heredoc_config(false, true);
settings.limits.max_body_bytes = 5;
let cmd = "cat << EOF\nAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\nEOF";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_denied(),
"exceeded size should deny when fallback_on_parse_error=false, \
got: {result:?}"
);
}
#[test]
fn heredoc_disabled_skips_all_extraction() {
let settings = crate::config::HeredocSettings {
enabled: false,
..Default::default()
};
let cmd = "python3 -c 'import shutil; shutil.rmtree(\"/tmp\")'";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_allowed(),
"with heredoc disabled, inline scripts should not be analyzed"
);
}
#[test]
fn safe_command_with_heredoc_trigger_still_allowed() {
let settings = heredoc_config(true, true);
let cmd = "python3 -c 'print(42)'";
let result = eval_with_heredoc(cmd, &settings);
assert!(
result.is_allowed(),
"safe heredoc content should be allowed"
);
}
}
mod graduation_tests {
use super::*;
use crate::config::{GraduationMode, ResponseConfig, SeverityOverrides};
use crate::packs::Severity;
fn enabled_config() -> ResponseConfig {
ResponseConfig {
enabled: true,
..ResponseConfig::default()
}
}
#[test]
fn disabled_config_returns_none() {
let config = ResponseConfig::default(); let result = determine_graduated_response(5, Severity::High, &config);
assert!(result.is_none());
}
#[test]
fn disabled_mode_returns_none() {
let mut config = enabled_config();
config.mode = GraduationMode::Disabled;
let result = determine_graduated_response(5, Severity::Medium, &config);
assert!(result.is_none());
}
#[test]
fn warning_only_always_warns() {
let mut config = enabled_config();
config.mode = GraduationMode::WarningOnly;
for count in [1, 5, 100] {
let result =
determine_graduated_response(count, Severity::Medium, &config).unwrap();
assert!(
matches!(result, GraduatedResponse::Warning { .. }),
"WarningOnly should always warn, got {:?}",
result
);
}
}
#[test]
fn paranoid_always_hard_blocks() {
let mut config = enabled_config();
config.mode = GraduationMode::Paranoid;
let result = determine_graduated_response(1, Severity::Medium, &config).unwrap();
assert!(matches!(result, GraduatedResponse::HardBlock { .. }));
}
#[test]
fn standard_mode_progression() {
let config = enabled_config();
let r = determine_graduated_response(1, Severity::High, &config).unwrap();
assert!(matches!(r, GraduatedResponse::Warning { occurrence: 1 }));
let r = determine_graduated_response(2, Severity::High, &config).unwrap();
assert!(matches!(r, GraduatedResponse::SoftBlock { occurrence: 2 }));
let r = determine_graduated_response(5, Severity::High, &config).unwrap();
assert!(matches!(r, GraduatedResponse::SoftBlock { occurrence: 5 }));
}
#[test]
fn strict_mode_immediate_soft_block() {
let mut config = enabled_config();
config.mode = GraduationMode::Strict;
let r = determine_graduated_response(1, Severity::Medium, &config).unwrap();
assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
let r =
determine_graduated_response(config.session_soft_block, Severity::Medium, &config)
.unwrap();
assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
}
#[test]
fn lenient_mode_doubles_thresholds() {
let mut config = enabled_config();
config.mode = GraduationMode::Lenient;
let r = determine_graduated_response(1, Severity::Medium, &config);
assert!(r.is_none());
let r = determine_graduated_response(2, Severity::Medium, &config).unwrap();
assert!(matches!(r, GraduatedResponse::Warning { .. }));
let r = determine_graduated_response(4, Severity::Medium, &config).unwrap();
assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
}
#[test]
fn severity_defaults_for_critical_and_low() {
let config = enabled_config();
let r = determine_graduated_response(1, Severity::Critical, &config).unwrap();
assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
let r = determine_graduated_response(1, Severity::Low, &config).unwrap();
assert!(matches!(r, GraduatedResponse::Warning { .. }));
}
#[test]
fn severity_override_takes_precedence() {
let mut config = enabled_config();
config.severity_overrides = SeverityOverrides {
critical: Some(GraduationMode::WarningOnly),
high: None,
medium: None,
low: Some(GraduationMode::Paranoid),
};
let r = determine_graduated_response(1, Severity::Critical, &config).unwrap();
assert!(matches!(r, GraduatedResponse::Warning { .. }));
let r = determine_graduated_response(1, Severity::Low, &config).unwrap();
assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
}
#[test]
fn apply_graduation_on_denied_result() {
let mut config = enabled_config();
config.session_warning_count = 1;
let mut result = EvaluationResult::denied_by_pack_pattern(
"core.git",
"reset-hard",
"Destroys uncommitted changes",
None,
Severity::High,
&[],
);
result.session_occurrence = Some(crate::session::OccurrenceSnapshot {
command_hash: "abc".to_string(),
session_count: 1,
distinct_commands: 1,
total_occurrences: 1,
});
result.apply_graduation(&config);
assert!(result.graduated_response.is_some());
assert!(matches!(
result.graduated_response,
Some(GraduatedResponse::Warning { occurrence: 1 })
));
}
#[test]
fn apply_graduation_skipped_when_disabled() {
let config = ResponseConfig::default(); let mut result = EvaluationResult::denied_by_pack("test", "reason", None);
result.session_occurrence = Some(crate::session::OccurrenceSnapshot {
command_hash: "abc".to_string(),
session_count: 5,
distinct_commands: 1,
total_occurrences: 5,
});
result.apply_graduation(&config);
assert!(result.graduated_response.is_none());
}
#[test]
fn apply_graduation_no_occurrence_data() {
let config = enabled_config();
let mut result = EvaluationResult::denied_by_pack("test", "reason", None);
result.apply_graduation(&config);
assert!(result.graduated_response.is_none());
}
#[test]
fn graduated_response_blocks() {
assert!(!GraduatedResponse::Warning { occurrence: 1 }.blocks());
assert!(GraduatedResponse::SoftBlock { occurrence: 2 }.blocks());
assert!(
GraduatedResponse::HardBlock {
total_occurrences: 5
}
.blocks()
);
}
#[test]
fn graduated_response_is_hard_block() {
assert!(!GraduatedResponse::Warning { occurrence: 1 }.is_hard_block());
assert!(!GraduatedResponse::SoftBlock { occurrence: 2 }.is_hard_block());
assert!(
GraduatedResponse::HardBlock {
total_occurrences: 5
}
.is_hard_block()
);
}
#[test]
fn graduated_response_labels() {
assert_eq!(
GraduatedResponse::Warning { occurrence: 3 }.label(),
"warning (occurrence #3)"
);
assert_eq!(
GraduatedResponse::SoftBlock { occurrence: 2 }.label(),
"soft block (occurrence #2)"
);
assert_eq!(
GraduatedResponse::HardBlock {
total_occurrences: 5
}
.label(),
"hard block (5 total occurrences)"
);
}
#[test]
fn bypass_method_labels() {
assert_eq!(BypassMethod::Force.label(), "force");
assert_eq!(BypassMethod::AllowOnce.label(), "allow_once");
}
#[test]
fn decision_mode_strings() {
assert_eq!(
GraduatedResponse::Warning { occurrence: 1 }.decision_mode(),
"warning"
);
assert_eq!(
GraduatedResponse::SoftBlock { occurrence: 1 }.decision_mode(),
"soft_block"
);
assert_eq!(
GraduatedResponse::HardBlock {
total_occurrences: 1
}
.decision_mode(),
"hard_block"
);
}
#[test]
fn standard_mode_history_count_at_soft_threshold_escalates_to_softblock() {
let config = enabled_config();
let r = determine_graduated_response_with_history(
1,
Some(config.history_soft_block),
Severity::High,
&config,
)
.unwrap();
assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
}
#[test]
fn standard_mode_history_count_at_hard_threshold_escalates_to_hardblock() {
let config = enabled_config();
let r = determine_graduated_response_with_history(
1,
Some(config.history_hard_block),
Severity::High,
&config,
)
.unwrap();
assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
}
#[test]
fn standard_mode_history_below_threshold_keeps_session_response() {
let config = enabled_config();
let r = determine_graduated_response_with_history(1, Some(1), Severity::High, &config)
.unwrap();
assert!(matches!(r, GraduatedResponse::Warning { occurrence: 1 }));
}
#[test]
fn paranoid_mode_ignores_history_count() {
let mut config = enabled_config();
config.mode = GraduationMode::Paranoid;
let r =
determine_graduated_response_with_history(1, Some(99), Severity::Medium, &config)
.unwrap();
assert!(matches!(r, GraduatedResponse::HardBlock { .. }));
}
#[test]
fn lenient_mode_history_can_escalate_when_session_says_none() {
let mut config = enabled_config();
config.mode = GraduationMode::Lenient;
let r = determine_graduated_response_with_history(
1,
Some(config.history_soft_block),
Severity::Medium,
&config,
)
.unwrap();
assert!(matches!(r, GraduatedResponse::SoftBlock { .. }));
}
#[test]
fn history_none_matches_legacy_signature() {
let config = enabled_config();
for sc in [0, 1, 2, 5, 10] {
for sev in [
Severity::Critical,
Severity::High,
Severity::Medium,
Severity::Low,
] {
let legacy = determine_graduated_response(sc, sev, &config);
let new_none =
determine_graduated_response_with_history(sc, None, sev, &config);
assert_eq!(legacy, new_none, "must match for sc={sc} sev={sev:?}");
}
}
}
#[test]
fn parse_history_window_recognized_units() {
use crate::config::ResponseConfig;
assert_eq!(
ResponseConfig::parse_history_window("24h"),
Some(chrono::Duration::hours(24))
);
assert_eq!(
ResponseConfig::parse_history_window("7d"),
Some(chrono::Duration::days(7))
);
assert_eq!(
ResponseConfig::parse_history_window("30m"),
Some(chrono::Duration::minutes(30))
);
assert_eq!(
ResponseConfig::parse_history_window("90s"),
Some(chrono::Duration::seconds(90))
);
assert_eq!(ResponseConfig::parse_history_window(""), None);
assert_eq!(ResponseConfig::parse_history_window("24x"), None);
}
#[test]
fn parse_history_window_rejects_negative_and_overflow() {
use crate::config::ResponseConfig;
assert_eq!(ResponseConfig::parse_history_window("-1h"), None);
assert_eq!(ResponseConfig::parse_history_window("-100d"), None);
assert_eq!(ResponseConfig::parse_history_window("99999999999d"), None);
assert_eq!(
ResponseConfig::parse_history_window("9999999999999999999s"),
None
);
assert_eq!(
ResponseConfig::parse_history_window("36500d"),
Some(chrono::Duration::days(36500))
);
}
#[test]
fn parse_history_window_handles_multibyte_trailing_char() {
use crate::config::ResponseConfig;
assert_eq!(ResponseConfig::parse_history_window("24é"), None);
}
}
}