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::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>,
}
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,
}
}
#[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,
}
}
#[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,
}
}
#[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,
}
}
#[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,
}
}
#[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,
}
}
#[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,
}
}
#[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,
}
}
#[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,
}
}
#[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,
}
}
#[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())
}
#[must_use]
pub fn pack_id(&self) -> Option<&str> {
self.pattern_info
.as_ref()
.and_then(|p| p.pack_id.as_deref())
}
}
#[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(|d| d.max_duration().is_zero() || d.is_exceeded())
}
#[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 compiled_overrides.check_allow(command) {
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 allow_once_match(command, allow_once_audit).is_some() {
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 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 matches!(sanitized, std::borrow::Cow::Owned(_)) && quick_reject {
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 allowlists
.match_exact_command_at_path(&normalized, project_path)
.is_some()
|| allowlists
.match_command_prefix_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) {
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) {
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 {
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 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" {
match rm_parse.as_ref() {
Some(crate::packs::core::filesystem::RmParseDecision::Allow) => {
continue; }
Some(crate::packs::core::filesystem::RmParseDecision::NoMatch) | None => {
if pack.matches_safe(command_for_packs) {
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 pack.matches_safe(command_for_packs) {
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 });
let Some(span) = matched_span else {
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((matched, layer, reason)) = first_allowlist_hit {
return EvaluationResult::allowed_by_allowlist(matched, layer, reason);
}
EvaluationResult::allowed()
}
#[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();
}
if compiled_overrides.check_allow(command) {
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 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 matches!(sanitized, std::borrow::Cow::Owned(_)) && 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 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,
});
}
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,
});
}
}
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 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 = 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 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 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 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 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,
}
}
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]);
}
#[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,
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,
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,
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,
};
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);
}
}
}