Skip to main content

vtcode_safety/command_safety/
unified.rs

1//! Unified Command Evaluator - Phase 5
2//!
3//! Merges CommandPolicyEvaluator with command_safety module to provide
4//! comprehensive command validation combining:
5//! - Policy-based rules (allow/deny prefixes, regexes, globs)
6//! - Safety rules (subcommand validation, dangerous patterns)
7//! - Shell parsing (decompose complex scripts)
8//! - Audit logging & caching
9
10use 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/// Detailed reason for evaluation result
19#[derive(Clone, Debug, PartialEq)]
20pub enum EvaluationReason {
21    /// Command allowed by policy rule
22    PolicyAllow(String),
23    /// Command denied by policy rule
24    PolicyDeny(String),
25    /// Command passed safety checks
26    SafetyAllow,
27    /// Command failed safety checks
28    SafetyDeny(String),
29    /// Hardcoded dangerous command detected
30    DangerousCommand(String),
31    /// Retrieved from cache
32    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/// Complete evaluation result
51#[derive(Clone, Debug)]
52pub struct EvaluationResult {
53    /// Whether the command is allowed
54    pub allowed: bool,
55    /// Primary reason for decision
56    pub primary_reason: EvaluationReason,
57    /// Secondary reasons (e.g., policy rule that matched)
58    pub(crate) secondary_reasons: Vec<String>,
59    /// Resolved command path (if available)
60    resolved_path: Option<PathBuf>,
61}
62
63/// Unified command evaluator combining policies and safety rules
64#[derive(Clone)]
65pub struct UnifiedCommandEvaluator {
66    // Safety components
67    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    /// Create a new unified evaluator with default components
81    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    /// Evaluate a command with full context (async)
91    ///
92    /// # Evaluation Pipeline
93    /// 1. Check cache
94    /// 2. Apply dangerous command detection
95    /// 3. Apply safety registry rules (with subcommand validation)
96    /// 4. Handle shell parsing if needed (bash -lc)
97    /// 5. Log audit entry (async)
98    /// 6. Cache result
99    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        // 1. Check cache first
112        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            // Note: Audit logging skipped for cached decisions (logged on original evaluation)
115            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        // 2. Check dangerous commands first (fail-fast)
124        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        // 3. Apply safety registry rules
140        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                // Passed registry, continue to database checks
155            }
156            SafetyDecision::Unknown => {
157                // Continue to database checks
158            }
159        }
160
161        // 4. Apply command database rules
162        // Note: Database rules are optional. Currently the registry covers the main use cases.
163        // In a production system, this would merge database rules with registry rules.
164        // For now, we skip explicit database check as the registry is comprehensive.
165
166        // 5. Handle shell parsing for bash -lc and similar patterns
167        // Note: For simplicity, we evaluate each sub-command non-recursively
168        // by applying the same checks. In production, this could be refactored to support recursion.
169        if let Some(scripts) = parse_bash_lc_commands(command) {
170            for script in scripts {
171                // Apply the same checks to each script without recursive call
172                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                // Check safety registry for sub-command
189                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        // 6. All checks passed
205        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    /// Evaluate with explicit policy check (requires external CommandPolicyEvaluator)
217    ///
218    /// This is a placeholder for integration with CommandPolicyEvaluator.
219    /// In a real implementation, this would:
220    /// 1. Check policy rules first (deny precedence)
221    /// 2. Then apply safety rules
222    /// 3. Merge results
223    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 explicitly denies, stop here
230        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        // Policy allows, continue with safety checks
240        self.evaluate(command).await
241    }
242
243    /// Get reference to the cache for metrics/debugging
244    pub fn cache(&self) -> &SafetyDecisionCache {
245        &self.cache
246    }
247
248    /// Get reference to the audit logger
249    fn audit_logger(&self) -> &SafetyAuditLogger {
250        &self.audit_logger
251    }
252
253    /// Get reference to the registry
254    pub fn registry(&self) -> &SafeCommandRegistry {
255        &self.registry
256    }
257
258    /// Get reference to the database
259    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        // git is in the default safe registry
296        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        // First evaluation
306        let result1 = evaluator.evaluate(&cmd).await.unwrap();
307        assert!(result1.allowed);
308
309        // Second evaluation (should be cached)
310        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        // bash -lc with mixed safe/unsafe commands
345        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        // Should detect the rm -rf in the sub-command
353    }
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        // Policy allows, git status passes safety checks
383        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        // Policy allows but safety rules deny
394        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        // git push is not in the allowed subcommands for git
410        let result = evaluator.evaluate(&["git".to_string(), "push".to_string()]).await.unwrap();
411        assert!(!result.allowed);
412    }
413}
414
415/// Policy-aware evaluator adapter for backward compatibility with CommandPolicyEvaluator
416///
417/// This adapter wraps UnifiedCommandEvaluator with policy rule evaluation,
418/// allowing gradual migration from CommandPolicyEvaluator to UnifiedCommandEvaluator.
419#[derive(Clone)]
420pub struct PolicyAwareEvaluator {
421    unified: Arc<UnifiedCommandEvaluator>,
422    /// Policy allow decision (if Some, policy layer is active)
423    allow_policy_decision: Option<bool>,
424    policy_reason: Option<String>,
425}
426
427impl PolicyAwareEvaluator {
428    /// Create a new policy-aware evaluator with default components
429    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    /// Create with explicit policy decision
438    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    /// Evaluate command with optional policy layer
447    pub(crate) async fn evaluate(&self, command: &[String]) -> Result<EvaluationResult> {
448        // Apply policy layer if configured
449        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            // No policy configured, use pure safety evaluation
453            self.unified.evaluate(command).await
454        }
455    }
456
457    /// Set policy decision (allows updating policy after creation)
458    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    /// Clear policy decision (revert to pure safety evaluation)
464    pub(crate) fn clear_policy(&mut self) {
465        self.allow_policy_decision = None;
466        self.policy_reason = None;
467    }
468
469    /// Get reference to the underlying evaluator for advanced access
470    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        // Policy allows, but safety rules should deny
508        assert!(!result.allowed);
509    }
510
511    #[tokio::test]
512    async fn policy_aware_mutable_set_policy() {
513        let mut evaluator = PolicyAwareEvaluator::new();
514        // Initially no policy
515        let result1 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
516        assert!(result1.allowed);
517
518        // Set deny policy
519        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        // Clear policy
524        evaluator.clear_policy();
525        let result3 = evaluator.evaluate(&["git".to_string(), "status".to_string()]).await.unwrap();
526        assert!(result3.allowed);
527    }
528}