1use crate::command_safety::{
11 AuditEntry, CommandDatabase, SafeCommandRegistry, SafetyAuditLogger, SafetyDecision, SafetyDecisionCache,
12 command_might_be_dangerous, parse_bash_lc_commands,
13};
14use anyhow::Result;
15use std::path::PathBuf;
16use std::sync::Arc;
17
18#[derive(Clone, Debug, PartialEq)]
20pub enum EvaluationReason {
21 PolicyAllow(String),
23 PolicyDeny(String),
25 SafetyAllow,
27 SafetyDeny(String),
29 DangerousCommand(String),
31 CacheHit(bool, String),
33}
34
35impl std::fmt::Display for EvaluationReason {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 Self::PolicyAllow(msg) => write!(f, "Policy Allow: {msg}"),
39 Self::PolicyDeny(msg) => write!(f, "Policy Deny: {msg}"),
40 Self::SafetyAllow => write!(f, "Safety Allow"),
41 Self::SafetyDeny(msg) => write!(f, "Safety Deny: {msg}"),
42 Self::DangerousCommand(msg) => write!(f, "Dangerous: {msg}"),
43 Self::CacheHit(allowed, msg) => {
44 write!(f, "Cache {} {}", if *allowed { "Allow" } else { "Deny" }, msg)
45 }
46 }
47 }
48}
49
50#[derive(Clone, Debug)]
52pub struct EvaluationResult {
53 pub allowed: bool,
55 pub primary_reason: EvaluationReason,
57 pub(crate) secondary_reasons: Vec<String>,
59 resolved_path: Option<PathBuf>,
61}
62
63#[derive(Clone)]
65pub struct UnifiedCommandEvaluator {
66 registry: SafeCommandRegistry,
68 database: CommandDatabase,
69 cache: SafetyDecisionCache,
70 audit_logger: SafetyAuditLogger,
71}
72
73impl UnifiedCommandEvaluator {
74 async fn log_audit_entry(&self, command: &[String], allowed: bool, reason: impl Into<String>, decision_type: &str) {
75 self.audit_logger
76 .log(AuditEntry::new(command.to_vec(), allowed, reason.into(), decision_type.to_string()))
77 .await;
78 }
79
80 pub fn new() -> Self {
82 Self {
83 registry: SafeCommandRegistry::new(),
84 database: CommandDatabase,
85 cache: SafetyDecisionCache::new(1000),
86 audit_logger: SafetyAuditLogger::new(true),
87 }
88 }
89
90 pub(crate) async fn evaluate(&self, command: &[String]) -> Result<EvaluationResult> {
100 if command.is_empty() {
101 return Ok(EvaluationResult {
102 allowed: false,
103 primary_reason: EvaluationReason::SafetyDeny("empty command".into()),
104 secondary_reasons: vec![],
105 resolved_path: None,
106 });
107 }
108
109 let command_text = command.join(" ");
110
111 if let Some(cached_decision) = self.cache.get(&command_text).await {
113 let reason = EvaluationReason::CacheHit(cached_decision.is_safe, cached_decision.reason.clone());
114 return Ok(EvaluationResult {
116 allowed: cached_decision.is_safe,
117 primary_reason: reason,
118 secondary_reasons: vec![],
119 resolved_path: None,
120 });
121 }
122
123 if command_might_be_dangerous(command) {
125 let result = EvaluationResult {
126 allowed: false,
127 primary_reason: EvaluationReason::DangerousCommand("matches dangerous patterns".into()),
128 secondary_reasons: vec![],
129 resolved_path: None,
130 };
131 self.log_audit_entry(command, false, "matches dangerous patterns", "Dangerous")
132 .await;
133 self.cache
134 .put(command_text.clone(), false, "dangerous command pattern".into())
135 .await;
136 return Ok(result);
137 }
138
139 let registry_decision = self.registry.is_safe(command);
141 match registry_decision {
142 SafetyDecision::Deny(reason) => {
143 let result = EvaluationResult {
144 allowed: false,
145 primary_reason: EvaluationReason::SafetyDeny(reason.clone()),
146 secondary_reasons: vec!["registry rule".into()],
147 resolved_path: None,
148 };
149 self.log_audit_entry(command, false, reason.clone(), "Deny").await;
150 self.cache.put(command_text.clone(), false, reason.clone()).await;
151 return Ok(result);
152 }
153 SafetyDecision::Allow => {
154 }
156 SafetyDecision::Unknown => {
157 }
159 }
160
161 if let Some(scripts) = parse_bash_lc_commands(command) {
170 for script in scripts {
171 if command_might_be_dangerous(&script) {
173 let result = EvaluationResult {
174 allowed: false,
175 primary_reason: EvaluationReason::DangerousCommand(format!(
176 "dangerous in sub-script: {}",
177 script.join(" ")
178 )),
179 secondary_reasons: vec![],
180 resolved_path: None,
181 };
182 self.cache
183 .put(command_text.clone(), false, result.primary_reason.to_string())
184 .await;
185 return Ok(result);
186 }
187
188 if let SafetyDecision::Deny(reason) = self.registry.is_safe(&script) {
190 let result = EvaluationResult {
191 allowed: false,
192 primary_reason: EvaluationReason::SafetyDeny(format!("sub-command denied: {reason}")),
193 secondary_reasons: vec![],
194 resolved_path: None,
195 };
196 self.cache
197 .put(command_text.clone(), false, result.primary_reason.to_string())
198 .await;
199 return Ok(result);
200 }
201 }
202 }
203
204 let result = EvaluationResult {
206 allowed: true,
207 primary_reason: EvaluationReason::SafetyAllow,
208 secondary_reasons: vec!["passed all safety checks".into()],
209 resolved_path: None,
210 };
211 self.log_audit_entry(command, true, "passed all safety checks", "Allow").await;
212 self.cache.put(command_text, true, "passed all safety checks".into()).await;
213 Ok(result)
214 }
215
216 pub async fn evaluate_with_policy(
224 &self,
225 command: &[String],
226 policy_allowed: bool,
227 policy_reason: &str,
228 ) -> Result<EvaluationResult> {
229 if !policy_allowed {
231 return Ok(EvaluationResult {
232 allowed: false,
233 primary_reason: EvaluationReason::PolicyDeny(policy_reason.into()),
234 secondary_reasons: vec![],
235 resolved_path: None,
236 });
237 }
238
239 self.evaluate(command).await
241 }
242
243 pub fn cache(&self) -> &SafetyDecisionCache {
245 &self.cache
246 }
247
248 fn audit_logger(&self) -> &SafetyAuditLogger {
250 &self.audit_logger
251 }
252
253 pub fn registry(&self) -> &SafeCommandRegistry {
255 &self.registry
256 }
257
258 pub fn database(&self) -> &CommandDatabase {
260 &self.database
261 }
262}
263
264impl Default for UnifiedCommandEvaluator {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[tokio::test]
275 async fn empty_command_denied() {
276 let evaluator = UnifiedCommandEvaluator::new();
277 let result = evaluator.evaluate(&[]).await.unwrap();
278 assert!(!result.allowed);
279 }
280
281 #[tokio::test]
282 async fn dangerous_command_denied() {
283 let evaluator = UnifiedCommandEvaluator::new();
284 let result = evaluator
285 .evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
286 .await
287 .unwrap();
288 assert!(!result.allowed);
289 matches!(result.primary_reason, EvaluationReason::DangerousCommand(_));
290 }
291
292 #[tokio::test]
293 async fn safe_command_allowed() {
294 let evaluator = UnifiedCommandEvaluator::new();
295 let result = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
297 assert!(result.allowed);
298 }
299
300 #[tokio::test]
301 async fn cache_hit_on_repeated_command() {
302 let evaluator = UnifiedCommandEvaluator::new();
303 let cmd = vec!["git".to_string(), "status".to_string()];
304
305 let result1 = evaluator.evaluate(&cmd).await.unwrap();
307 assert!(result1.allowed);
308
309 let result2 = evaluator.evaluate(&cmd).await.unwrap();
311 assert!(result2.allowed);
312 matches!(result2.primary_reason, EvaluationReason::CacheHit(true, _));
313 assert_eq!(evaluator.audit_logger().count().await, 1);
314 }
315
316 #[tokio::test]
317 async fn dangerous_command_is_audited() {
318 let evaluator = UnifiedCommandEvaluator::new();
319 evaluator
320 .evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
321 .await
322 .unwrap();
323
324 let entries = evaluator.audit_logger().entries().await;
325 assert_eq!(entries.len(), 1);
326 assert!(!entries[0].allowed);
327 assert_eq!(entries[0].decision_type, "Dangerous");
328 }
329
330 #[tokio::test]
331 async fn safe_command_is_audited() {
332 let evaluator = UnifiedCommandEvaluator::new();
333 evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
334
335 let entries = evaluator.audit_logger().entries().await;
336 assert_eq!(entries.len(), 1);
337 assert!(entries[0].allowed);
338 assert_eq!(entries[0].decision_type, "Allow");
339 }
340
341 #[tokio::test]
342 async fn bash_lc_decomposition() {
343 let evaluator = UnifiedCommandEvaluator::new();
344 let cmd = vec![
346 "bash".to_string(),
347 "-lc".to_string(),
348 "git status && rm -rf /".to_string(),
349 ];
350 let result = evaluator.evaluate(&cmd).await.unwrap();
351 assert!(!result.allowed);
352 }
354
355 #[test]
356 fn evaluation_reason_display() {
357 let reason = EvaluationReason::PolicyAllow("test".into());
358 assert_eq!(reason.to_string(), "Policy Allow: test");
359
360 let reason = EvaluationReason::SafetyDeny("forbidden".into());
361 assert_eq!(reason.to_string(), "Safety Deny: forbidden");
362 }
363
364 #[tokio::test]
365 async fn policy_deny_stops_evaluation() {
366 let evaluator = UnifiedCommandEvaluator::new();
367 let result = evaluator
368 .evaluate_with_policy(&["git".to_string(), "status".to_string()], false, "policy blocked")
369 .await
370 .unwrap();
371 assert!(!result.allowed);
372 matches!(result.primary_reason, EvaluationReason::PolicyDeny(_));
373 }
374
375 #[tokio::test]
376 async fn policy_allow_continues_to_safety_checks() {
377 let evaluator = UnifiedCommandEvaluator::new();
378 let result = evaluator
379 .evaluate_with_policy(&["git".to_string(), "status".to_string()], true, "policy allowed")
380 .await
381 .unwrap();
382 assert!(result.allowed);
384 }
385
386 #[tokio::test]
387 async fn safety_deny_overrides_policy_allow() {
388 let evaluator = UnifiedCommandEvaluator::new();
389 let result = evaluator
390 .evaluate_with_policy(&["rm".to_string(), "-rf".to_string(), "/".to_string()], true, "policy allowed")
391 .await
392 .unwrap();
393 assert!(!result.allowed);
395 matches!(result.primary_reason, EvaluationReason::DangerousCommand(_));
396 }
397
398 #[tokio::test]
399 async fn evaluation_result_contains_reasons() {
400 let evaluator = UnifiedCommandEvaluator::new();
401 let result = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
402 assert!(result.allowed);
403 assert!(!result.secondary_reasons.is_empty());
404 }
405
406 #[tokio::test]
407 async fn forbidden_git_subcommand_denied() {
408 let evaluator = UnifiedCommandEvaluator::new();
409 let result = evaluator.evaluate(&["git".to_string(), "push".to_string()]).await.unwrap();
411 assert!(!result.allowed);
412 }
413}
414
415#[derive(Clone)]
420pub struct PolicyAwareEvaluator {
421 unified: Arc<UnifiedCommandEvaluator>,
422 allow_policy_decision: Option<bool>,
424 policy_reason: Option<String>,
425}
426
427impl PolicyAwareEvaluator {
428 pub(crate) fn new() -> Self {
430 Self {
431 unified: Arc::new(UnifiedCommandEvaluator::new()),
432 allow_policy_decision: None,
433 policy_reason: None,
434 }
435 }
436
437 pub(crate) fn with_policy(allow_policy_decision: bool, policy_reason: impl Into<String>) -> Self {
439 Self {
440 unified: Arc::new(UnifiedCommandEvaluator::new()),
441 allow_policy_decision: Some(allow_policy_decision),
442 policy_reason: Some(policy_reason.into()),
443 }
444 }
445
446 pub(crate) async fn evaluate(&self, command: &[String]) -> Result<EvaluationResult> {
448 if let (Some(policy_allowed), Some(reason)) = (&self.allow_policy_decision, &self.policy_reason) {
450 self.unified.evaluate_with_policy(command, *policy_allowed, reason).await
451 } else {
452 self.unified.evaluate(command).await
454 }
455 }
456
457 pub(crate) fn set_policy(&mut self, allowed: bool, reason: impl Into<String>) {
459 self.allow_policy_decision = Some(allowed);
460 self.policy_reason = Some(reason.into());
461 }
462
463 pub(crate) fn clear_policy(&mut self) {
465 self.allow_policy_decision = None;
466 self.policy_reason = None;
467 }
468
469 pub fn unified(&self) -> Arc<UnifiedCommandEvaluator> {
471 Arc::clone(&self.unified)
472 }
473}
474
475impl Default for PolicyAwareEvaluator {
476 fn default() -> Self {
477 Self::new()
478 }
479}
480
481#[cfg(test)]
482mod adapter_tests {
483 use super::*;
484
485 #[tokio::test]
486 async fn policy_aware_without_policy_uses_safety() {
487 let evaluator = PolicyAwareEvaluator::new();
488 let result = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
489 assert!(result.allowed);
490 }
491
492 #[tokio::test]
493 async fn policy_aware_with_deny_policy_blocks_safe_command() {
494 let evaluator = PolicyAwareEvaluator::with_policy(false, "policy blocked");
495 let result = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
496 assert!(!result.allowed);
497 matches!(result.primary_reason, EvaluationReason::PolicyDeny(_));
498 }
499
500 #[tokio::test]
501 async fn policy_aware_with_allow_policy_still_blocks_dangerous() {
502 let evaluator = PolicyAwareEvaluator::with_policy(true, "policy allowed");
503 let result = evaluator
504 .evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
505 .await
506 .unwrap();
507 assert!(!result.allowed);
509 }
510
511 #[tokio::test]
512 async fn policy_aware_mutable_set_policy() {
513 let mut evaluator = PolicyAwareEvaluator::new();
514 let result1 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
516 assert!(result1.allowed);
517
518 evaluator.set_policy(false, "policy blocked");
520 let result2 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
521 assert!(!result2.allowed);
522
523 evaluator.clear_policy();
525 let result3 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
526 assert!(result3.allowed);
527 }
528}