use crate::command_safety::{
AuditEntry, CommandDatabase, SafeCommandRegistry, SafetyAuditLogger, SafetyDecision,
SafetyDecisionCache, command_might_be_dangerous, parse_bash_lc_commands,
};
use anyhow::Result;
use std::path::PathBuf;
use std::sync::Arc;
#[derive(Clone, Debug, PartialEq)]
pub enum EvaluationReason {
PolicyAllow(String),
PolicyDeny(String),
SafetyAllow,
SafetyDeny(String),
DangerousCommand(String),
CacheHit(bool, String),
}
impl std::fmt::Display for EvaluationReason {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::PolicyAllow(msg) => write!(f, "Policy Allow: {}", msg),
Self::PolicyDeny(msg) => write!(f, "Policy Deny: {}", msg),
Self::SafetyAllow => write!(f, "Safety Allow"),
Self::SafetyDeny(msg) => write!(f, "Safety Deny: {}", msg),
Self::DangerousCommand(msg) => write!(f, "Dangerous: {}", msg),
Self::CacheHit(allowed, msg) => {
write!(
f,
"Cache {} {}",
if *allowed { "Allow" } else { "Deny" },
msg
)
}
}
}
}
#[derive(Clone, Debug)]
pub struct EvaluationResult {
pub allowed: bool,
pub primary_reason: EvaluationReason,
pub secondary_reasons: Vec<String>,
pub resolved_path: Option<PathBuf>,
}
#[derive(Clone)]
pub struct UnifiedCommandEvaluator {
registry: SafeCommandRegistry,
database: CommandDatabase,
cache: SafetyDecisionCache,
audit_logger: SafetyAuditLogger,
}
impl UnifiedCommandEvaluator {
async fn log_audit_entry(
&self,
command: &[String],
allowed: bool,
reason: impl Into<String>,
decision_type: &str,
) {
self.audit_logger
.log(AuditEntry::new(
command.to_vec(),
allowed,
reason.into(),
decision_type.to_string(),
))
.await;
}
pub fn new() -> Self {
Self {
registry: SafeCommandRegistry::new(),
database: CommandDatabase,
cache: SafetyDecisionCache::new(1000),
audit_logger: SafetyAuditLogger::new(true),
}
}
pub async fn evaluate(&self, command: &[String]) -> Result<EvaluationResult> {
if command.is_empty() {
return Ok(EvaluationResult {
allowed: false,
primary_reason: EvaluationReason::SafetyDeny("empty command".into()),
secondary_reasons: vec![],
resolved_path: None,
});
}
let command_text = command.join(" ");
if let Some(cached_decision) = self.cache.get(&command_text).await {
let reason =
EvaluationReason::CacheHit(cached_decision.is_safe, cached_decision.reason.clone());
return Ok(EvaluationResult {
allowed: cached_decision.is_safe,
primary_reason: reason,
secondary_reasons: vec![],
resolved_path: None,
});
}
if command_might_be_dangerous(command) {
let result = EvaluationResult {
allowed: false,
primary_reason: EvaluationReason::DangerousCommand(
"matches dangerous patterns".into(),
),
secondary_reasons: vec![],
resolved_path: None,
};
self.log_audit_entry(command, false, "matches dangerous patterns", "Dangerous")
.await;
self.cache
.put(
command_text.clone(),
false,
"dangerous command pattern".into(),
)
.await;
return Ok(result);
}
let registry_decision = self.registry.is_safe(command);
match registry_decision {
SafetyDecision::Deny(reason) => {
let result = EvaluationResult {
allowed: false,
primary_reason: EvaluationReason::SafetyDeny(reason.clone()),
secondary_reasons: vec!["registry rule".into()],
resolved_path: None,
};
self.log_audit_entry(command, false, reason.clone(), "Deny")
.await;
self.cache
.put(command_text.clone(), false, reason.clone())
.await;
return Ok(result);
}
SafetyDecision::Allow => {
}
SafetyDecision::Unknown => {
}
}
if let Some(scripts) = parse_bash_lc_commands(command) {
for script in scripts {
if command_might_be_dangerous(&script) {
let result = EvaluationResult {
allowed: false,
primary_reason: EvaluationReason::DangerousCommand(format!(
"dangerous in sub-script: {}",
script.join(" ")
)),
secondary_reasons: vec![],
resolved_path: None,
};
self.cache
.put(
command_text.clone(),
false,
result.primary_reason.to_string(),
)
.await;
return Ok(result);
}
if let SafetyDecision::Deny(reason) = self.registry.is_safe(&script) {
let result = EvaluationResult {
allowed: false,
primary_reason: EvaluationReason::SafetyDeny(format!(
"sub-command denied: {}",
reason
)),
secondary_reasons: vec![],
resolved_path: None,
};
self.cache
.put(
command_text.clone(),
false,
result.primary_reason.to_string(),
)
.await;
return Ok(result);
}
}
}
let result = EvaluationResult {
allowed: true,
primary_reason: EvaluationReason::SafetyAllow,
secondary_reasons: vec!["passed all safety checks".into()],
resolved_path: None,
};
self.log_audit_entry(command, true, "passed all safety checks", "Allow")
.await;
self.cache
.put(command_text, true, "passed all safety checks".into())
.await;
Ok(result)
}
pub async fn evaluate_with_policy(
&self,
command: &[String],
policy_allowed: bool,
policy_reason: &str,
) -> Result<EvaluationResult> {
if !policy_allowed {
return Ok(EvaluationResult {
allowed: false,
primary_reason: EvaluationReason::PolicyDeny(policy_reason.into()),
secondary_reasons: vec![],
resolved_path: None,
});
}
self.evaluate(command).await
}
pub fn cache(&self) -> &SafetyDecisionCache {
&self.cache
}
pub fn audit_logger(&self) -> &SafetyAuditLogger {
&self.audit_logger
}
pub fn registry(&self) -> &SafeCommandRegistry {
&self.registry
}
pub fn database(&self) -> &CommandDatabase {
&self.database
}
}
impl Default for UnifiedCommandEvaluator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn empty_command_denied() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator.evaluate(&[]).await.unwrap();
assert!(!result.allowed);
}
#[tokio::test]
async fn dangerous_command_denied() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator
.evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
.await
.unwrap();
assert!(!result.allowed);
matches!(result.primary_reason, EvaluationReason::DangerousCommand(_));
}
#[tokio::test]
async fn safe_command_allowed() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
assert!(result.allowed);
}
#[tokio::test]
async fn cache_hit_on_repeated_command() {
let evaluator = UnifiedCommandEvaluator::new();
let cmd = vec!["git".to_string(), "status".to_string()];
let result1 = evaluator.evaluate(&cmd).await.unwrap();
assert!(result1.allowed);
let result2 = evaluator.evaluate(&cmd).await.unwrap();
assert!(result2.allowed);
matches!(result2.primary_reason, EvaluationReason::CacheHit(true, _));
assert_eq!(evaluator.audit_logger().count().await, 1);
}
#[tokio::test]
async fn dangerous_command_is_audited() {
let evaluator = UnifiedCommandEvaluator::new();
evaluator
.evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
.await
.unwrap();
let entries = evaluator.audit_logger().entries().await;
assert_eq!(entries.len(), 1);
assert!(!entries[0].allowed);
assert_eq!(entries[0].decision_type, "Dangerous");
}
#[tokio::test]
async fn safe_command_is_audited() {
let evaluator = UnifiedCommandEvaluator::new();
evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
let entries = evaluator.audit_logger().entries().await;
assert_eq!(entries.len(), 1);
assert!(entries[0].allowed);
assert_eq!(entries[0].decision_type, "Allow");
}
#[tokio::test]
async fn bash_lc_decomposition() {
let evaluator = UnifiedCommandEvaluator::new();
let cmd = vec![
"bash".to_string(),
"-lc".to_string(),
"git status && rm -rf /".to_string(),
];
let result = evaluator.evaluate(&cmd).await.unwrap();
assert!(!result.allowed);
}
#[test]
fn evaluation_reason_display() {
let reason = EvaluationReason::PolicyAllow("test".into());
assert_eq!(reason.to_string(), "Policy Allow: test");
let reason = EvaluationReason::SafetyDeny("forbidden".into());
assert_eq!(reason.to_string(), "Safety Deny: forbidden");
}
#[tokio::test]
async fn policy_deny_stops_evaluation() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator
.evaluate_with_policy(
&["git".to_string(), "status".to_string()],
false,
"policy blocked",
)
.await
.unwrap();
assert!(!result.allowed);
matches!(result.primary_reason, EvaluationReason::PolicyDeny(_));
}
#[tokio::test]
async fn policy_allow_continues_to_safety_checks() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator
.evaluate_with_policy(
&["git".to_string(), "status".to_string()],
true,
"policy allowed",
)
.await
.unwrap();
assert!(result.allowed);
}
#[tokio::test]
async fn safety_deny_overrides_policy_allow() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator
.evaluate_with_policy(
&["rm".to_string(), "-rf".to_string(), "/".to_string()],
true,
"policy allowed",
)
.await
.unwrap();
assert!(!result.allowed);
matches!(result.primary_reason, EvaluationReason::DangerousCommand(_));
}
#[tokio::test]
async fn evaluation_result_contains_reasons() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
assert!(result.allowed);
assert!(!result.secondary_reasons.is_empty());
}
#[tokio::test]
async fn forbidden_git_subcommand_denied() {
let evaluator = UnifiedCommandEvaluator::new();
let result = evaluator
.evaluate(&["git".to_string(), "push".to_string()])
.await
.unwrap();
assert!(!result.allowed);
}
}
#[derive(Clone)]
pub struct PolicyAwareEvaluator {
unified: Arc<UnifiedCommandEvaluator>,
allow_policy_decision: Option<bool>,
policy_reason: Option<String>,
}
impl PolicyAwareEvaluator {
pub fn new() -> Self {
Self {
unified: Arc::new(UnifiedCommandEvaluator::new()),
allow_policy_decision: None,
policy_reason: None,
}
}
pub fn with_policy(allow_policy_decision: bool, policy_reason: impl Into<String>) -> Self {
Self {
unified: Arc::new(UnifiedCommandEvaluator::new()),
allow_policy_decision: Some(allow_policy_decision),
policy_reason: Some(policy_reason.into()),
}
}
pub async fn evaluate(&self, command: &[String]) -> Result<EvaluationResult> {
if let (Some(policy_allowed), Some(reason)) =
(&self.allow_policy_decision, &self.policy_reason)
{
self.unified
.evaluate_with_policy(command, *policy_allowed, reason)
.await
} else {
self.unified.evaluate(command).await
}
}
pub fn set_policy(&mut self, allowed: bool, reason: impl Into<String>) {
self.allow_policy_decision = Some(allowed);
self.policy_reason = Some(reason.into());
}
pub fn clear_policy(&mut self) {
self.allow_policy_decision = None;
self.policy_reason = None;
}
pub fn unified(&self) -> Arc<UnifiedCommandEvaluator> {
Arc::clone(&self.unified)
}
}
impl Default for PolicyAwareEvaluator {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod adapter_tests {
use super::*;
#[tokio::test]
async fn policy_aware_without_policy_uses_safety() {
let evaluator = PolicyAwareEvaluator::new();
let result = evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
assert!(result.allowed);
}
#[tokio::test]
async fn policy_aware_with_deny_policy_blocks_safe_command() {
let evaluator = PolicyAwareEvaluator::with_policy(false, "policy blocked");
let result = evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
assert!(!result.allowed);
matches!(result.primary_reason, EvaluationReason::PolicyDeny(_));
}
#[tokio::test]
async fn policy_aware_with_allow_policy_still_blocks_dangerous() {
let evaluator = PolicyAwareEvaluator::with_policy(true, "policy allowed");
let result = evaluator
.evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
.await
.unwrap();
assert!(!result.allowed);
}
#[tokio::test]
async fn policy_aware_mutable_set_policy() {
let mut evaluator = PolicyAwareEvaluator::new();
let result1 = evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
assert!(result1.allowed);
evaluator.set_policy(false, "policy blocked");
let result2 = evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
assert!(!result2.allowed);
evaluator.clear_policy();
let result3 = evaluator
.evaluate(&["git".to_string(), "status".to_string()])
.await
.unwrap();
assert!(result3.allowed);
}
}