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 drop(
320 evaluator
321 .evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
322 .await
323 .unwrap(),
324 );
325
326 let entries = evaluator.audit_logger().entries().await;
327 assert_eq!(entries.len(), 1);
328 assert!(!entries[0].allowed);
329 assert_eq!(entries[0].decision_type, "Dangerous");
330 }
331
332 #[tokio::test]
333 async fn safe_command_is_audited() {
334 let evaluator = UnifiedCommandEvaluator::new();
335 drop(evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap());
336
337 let entries = evaluator.audit_logger().entries().await;
338 assert_eq!(entries.len(), 1);
339 assert!(entries[0].allowed);
340 assert_eq!(entries[0].decision_type, "Allow");
341 }
342
343 #[tokio::test]
344 async fn bash_lc_decomposition() {
345 let evaluator = UnifiedCommandEvaluator::new();
346 let cmd = vec![
348 "bash".to_string(),
349 "-lc".to_string(),
350 "git status && rm -rf /".to_string(),
351 ];
352 let result = evaluator.evaluate(&cmd).await.unwrap();
353 assert!(!result.allowed);
354 }
356
357 #[test]
358 fn evaluation_reason_display() {
359 let reason = EvaluationReason::PolicyAllow("test".into());
360 assert_eq!(reason.to_string(), "Policy Allow: test");
361
362 let reason = EvaluationReason::SafetyDeny("forbidden".into());
363 assert_eq!(reason.to_string(), "Safety Deny: forbidden");
364 }
365
366 #[tokio::test]
367 async fn policy_deny_stops_evaluation() {
368 let evaluator = UnifiedCommandEvaluator::new();
369 let result = evaluator
370 .evaluate_with_policy(&["git".to_string(), "status".to_string()], false, "policy blocked")
371 .await
372 .unwrap();
373 assert!(!result.allowed);
374 matches!(result.primary_reason, EvaluationReason::PolicyDeny(_));
375 }
376
377 #[tokio::test]
378 async fn policy_allow_continues_to_safety_checks() {
379 let evaluator = UnifiedCommandEvaluator::new();
380 let result = evaluator
381 .evaluate_with_policy(&["git".to_string(), "status".to_string()], true, "policy allowed")
382 .await
383 .unwrap();
384 assert!(result.allowed);
386 }
387
388 #[tokio::test]
389 async fn safety_deny_overrides_policy_allow() {
390 let evaluator = UnifiedCommandEvaluator::new();
391 let result = evaluator
392 .evaluate_with_policy(&["rm".to_string(), "-rf".to_string(), "/".to_string()], true, "policy allowed")
393 .await
394 .unwrap();
395 assert!(!result.allowed);
397 matches!(result.primary_reason, EvaluationReason::DangerousCommand(_));
398 }
399
400 #[tokio::test]
401 async fn evaluation_result_contains_reasons() {
402 let evaluator = UnifiedCommandEvaluator::new();
403 let result = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
404 assert!(result.allowed);
405 assert!(!result.secondary_reasons.is_empty());
406 }
407
408 #[tokio::test]
409 async fn forbidden_git_subcommand_denied() {
410 let evaluator = UnifiedCommandEvaluator::new();
411 let result = evaluator.evaluate(&["git".to_string(), "push".to_string()]).await.unwrap();
413 assert!(!result.allowed);
414 }
415}
416
417#[derive(Clone)]
422pub struct PolicyAwareEvaluator {
423 unified: Arc<UnifiedCommandEvaluator>,
424 allow_policy_decision: Option<bool>,
426 policy_reason: Option<String>,
427}
428
429impl PolicyAwareEvaluator {
430 pub(crate) fn new() -> Self {
432 Self {
433 unified: Arc::new(UnifiedCommandEvaluator::new()),
434 allow_policy_decision: None,
435 policy_reason: None,
436 }
437 }
438
439 pub(crate) fn with_policy(allow_policy_decision: bool, policy_reason: impl Into<String>) -> Self {
441 Self {
442 unified: Arc::new(UnifiedCommandEvaluator::new()),
443 allow_policy_decision: Some(allow_policy_decision),
444 policy_reason: Some(policy_reason.into()),
445 }
446 }
447
448 pub(crate) async fn evaluate(&self, command: &[String]) -> Result<EvaluationResult> {
450 if let (Some(policy_allowed), Some(reason)) = (&self.allow_policy_decision, &self.policy_reason) {
452 self.unified.evaluate_with_policy(command, *policy_allowed, reason).await
453 } else {
454 self.unified.evaluate(command).await
456 }
457 }
458
459 pub(crate) fn set_policy(&mut self, allowed: bool, reason: impl Into<String>) {
461 self.allow_policy_decision = Some(allowed);
462 self.policy_reason = Some(reason.into());
463 }
464
465 pub(crate) fn clear_policy(&mut self) {
467 self.allow_policy_decision = None;
468 self.policy_reason = None;
469 }
470
471 pub fn unified(&self) -> Arc<UnifiedCommandEvaluator> {
473 Arc::clone(&self.unified)
474 }
475}
476
477impl Default for PolicyAwareEvaluator {
478 fn default() -> Self {
479 Self::new()
480 }
481}
482
483#[cfg(test)]
484mod adapter_tests {
485 use super::*;
486
487 #[tokio::test]
488 async fn policy_aware_without_policy_uses_safety() {
489 let evaluator = PolicyAwareEvaluator::new();
490 let result = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
491 assert!(result.allowed);
492 }
493
494 #[tokio::test]
495 async fn policy_aware_with_deny_policy_blocks_safe_command() {
496 let evaluator = PolicyAwareEvaluator::with_policy(false, "policy blocked");
497 let result = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
498 assert!(!result.allowed);
499 matches!(result.primary_reason, EvaluationReason::PolicyDeny(_));
500 }
501
502 #[tokio::test]
503 async fn policy_aware_with_allow_policy_still_blocks_dangerous() {
504 let evaluator = PolicyAwareEvaluator::with_policy(true, "policy allowed");
505 let result = evaluator
506 .evaluate(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
507 .await
508 .unwrap();
509 assert!(!result.allowed);
511 }
512
513 #[tokio::test]
514 async fn policy_aware_mutable_set_policy() {
515 let mut evaluator = PolicyAwareEvaluator::new();
516 let result1 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
518 assert!(result1.allowed);
519
520 evaluator.set_policy(false, "policy blocked");
522 let result2 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
523 assert!(!result2.allowed);
524
525 evaluator.clear_policy();
527 let result3 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
528 assert!(result3.allowed);
529 }
530}