Skip to main content

vtcode_safety/exec_policy/
manager.rs

1//! Execution policy manager.
2//!
3//! Coordinates policy evaluation, approval requirements, and sandbox enforcement.
4//! Inspired by Codex's ExecPolicyManager pattern.
5
6use super::{
7    approval::{AskForApproval, ExecApprovalRequirement, ExecPolicyAmendment},
8    policy::{Decision, Policy, PolicyEvaluation, RuleMatch},
9};
10use crate::command_safety::command_might_be_dangerous;
11use crate::sandboxing::SandboxPolicy;
12use anyhow::{Context, Result};
13use std::{
14    collections::HashSet,
15    path::{Path, PathBuf},
16    sync::Arc,
17};
18use tokio::sync::RwLock;
19
20const PROMPT_CONFLICT_REASON: &str = "approval required by policy, but AskForApproval is set to Never";
21const REJECT_SANDBOX_APPROVAL_REASON: &str =
22    "approval required by policy, but AskForApproval::Reject.sandbox_approval is set";
23const REJECT_RULES_APPROVAL_REASON: &str = "approval required by policy rule, but AskForApproval::Reject.rules is set";
24
25fn prompt_is_rejected_by_policy(approval_policy: AskForApproval, prompt_is_rule: bool) -> Option<&'static str> {
26    if prompt_is_rule {
27        if !approval_policy.rejects_rule_prompt() {
28            return None;
29        }
30
31        return Some(if matches!(approval_policy, AskForApproval::Never) {
32            PROMPT_CONFLICT_REASON
33        } else {
34            REJECT_RULES_APPROVAL_REASON
35        });
36    }
37
38    if !approval_policy.rejects_sandbox_prompt() {
39        return None;
40    }
41
42    Some(if matches!(approval_policy, AskForApproval::Never) {
43        PROMPT_CONFLICT_REASON
44    } else {
45        REJECT_SANDBOX_APPROVAL_REASON
46    })
47}
48
49/// Configuration for the execution policy manager.
50#[derive(Debug, Clone)]
51pub struct ExecPolicyConfig {
52    /// Default sandbox policy for commands.
53    default_sandbox_policy: SandboxPolicy,
54
55    /// Default approval behavior.
56    default_approval: AskForApproval,
57
58    /// Whether to apply heuristics for unknown commands.
59    use_heuristics: bool,
60
61    /// Maximum command length before requiring confirmation.
62    max_auto_approve_length: usize,
63}
64
65impl Default for ExecPolicyConfig {
66    fn default() -> Self {
67        Self {
68            default_sandbox_policy: SandboxPolicy::read_only(),
69            default_approval: AskForApproval::UnlessTrusted,
70            use_heuristics: true,
71            max_auto_approve_length: 256,
72        }
73    }
74}
75
76/// Manages execution policies and authorization decisions.
77pub struct ExecPolicyManager {
78    /// The current policy.
79    policy: RwLock<Policy>,
80
81    /// Trusted command patterns.
82    trusted_patterns: RwLock<Vec<ExecPolicyAmendment>>,
83
84    /// Active sandbox policy.
85    sandbox_policy: RwLock<SandboxPolicy>,
86
87    /// Configuration.
88    config: ExecPolicyConfig,
89
90    /// Workspace root for path validation.
91    #[expect(dead_code)]
92    workspace_root: PathBuf,
93
94    /// Commands that have been pre-approved this session.
95    session_approved: RwLock<HashSet<String>>,
96}
97
98impl ExecPolicyManager {
99    /// Create a new policy manager.
100    fn new(workspace_root: PathBuf, config: ExecPolicyConfig) -> Self {
101        Self {
102            policy: RwLock::new(Policy::empty()),
103            trusted_patterns: RwLock::new(Vec::new()),
104            sandbox_policy: RwLock::new(config.default_sandbox_policy.clone()),
105            config,
106            workspace_root,
107            session_approved: RwLock::new(HashSet::new()),
108        }
109    }
110
111    /// Create with default configuration.
112    fn with_defaults(workspace_root: PathBuf) -> Self {
113        Self::new(workspace_root, ExecPolicyConfig::default())
114    }
115
116    /// Load policy from a file.
117    pub async fn load_policy(&self, path: &Path) -> Result<()> {
118        let parser = super::parser::PolicyParser::new();
119        let loaded_policy = parser.load_file(path).await.context("Failed to load policy file")?;
120
121        let mut policy = self.policy.write().await;
122        *policy = loaded_policy;
123        Ok(())
124    }
125
126    /// Add a prefix rule to the policy.
127    async fn add_prefix_rule(&self, pattern: &[String], decision: Decision) -> Result<()> {
128        let mut policy = self.policy.write().await;
129        policy.add_prefix_rule(pattern, decision)
130    }
131
132    /// Add a trusted pattern amendment.
133    async fn add_trusted_pattern(&self, amendment: ExecPolicyAmendment) {
134        let mut patterns = self.trusted_patterns.write().await;
135        patterns.push(amendment);
136    }
137
138    /// Set the sandbox policy.
139    pub async fn set_sandbox_policy(&self, policy: SandboxPolicy) {
140        let mut sandbox = self.sandbox_policy.write().await;
141        *sandbox = policy;
142    }
143
144    /// Get the current sandbox policy.
145    pub async fn sandbox_policy(&self) -> SandboxPolicy {
146        self.sandbox_policy.read().await.clone()
147    }
148
149    /// Check if a command requires approval.
150    async fn check_approval(&self, command: &[String]) -> ExecApprovalRequirement {
151        // Check if already approved this session
152        let command_key = command.join(" ");
153        {
154            let approved = self.session_approved.read().await;
155            if approved.contains(&command_key) {
156                return ExecApprovalRequirement::skip();
157            }
158        }
159
160        // Check trusted patterns
161        {
162            let patterns = self.trusted_patterns.read().await;
163            for pattern in patterns.iter() {
164                if pattern.matches(command) {
165                    return ExecApprovalRequirement::skip();
166                }
167            }
168        }
169
170        // Check policy rules
171        let policy = self.policy.read().await;
172        let rule_match = policy.check(command);
173
174        // Apply heuristics for non-policy matches
175        let decision = match &rule_match {
176            RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
177            RuleMatch::HeuristicsRuleMatch { .. } => self.heuristics_decision(command),
178        };
179
180        match decision {
181            Decision::Allow => ExecApprovalRequirement::skip(),
182            Decision::Prompt => {
183                let prompt_is_rule =
184                    matches!(rule_match, RuleMatch::PrefixRuleMatch { decision: Decision::Prompt, .. });
185
186                match prompt_is_rejected_by_policy(self.config.default_approval, prompt_is_rule) {
187                    Some(reason) => ExecApprovalRequirement::forbidden(reason),
188                    None => ExecApprovalRequirement::needs_approval(self.format_approval_reason(command, &rule_match)),
189                }
190            }
191            Decision::Forbidden => {
192                ExecApprovalRequirement::forbidden(self.format_forbidden_reason(command, &rule_match))
193            }
194        }
195    }
196
197    /// Check multiple commands and return combined approval requirement.
198    pub async fn check_approval_batch(&self, commands: &[Vec<String>]) -> ExecApprovalRequirement {
199        let mut needs_approval_flag = false;
200        let mut reasons = Vec::new();
201
202        for command in commands {
203            let approval = self.check_approval(command).await;
204            if approval.is_forbidden() {
205                return approval;
206            }
207            if approval.requires_approval() {
208                needs_approval_flag = true;
209                if let ExecApprovalRequirement::NeedsApproval { reason: Some(r), .. } = &approval {
210                    reasons.push(r.clone());
211                }
212            }
213        }
214
215        if needs_approval_flag {
216            ExecApprovalRequirement::needs_approval(reasons.join("; "))
217        } else {
218            ExecApprovalRequirement::skip()
219        }
220    }
221
222    /// Mark a command as approved for this session.
223    async fn approve_command(&self, command: &[String]) {
224        let command_key = command.join(" ");
225        let mut approved = self.session_approved.write().await;
226        approved.insert(command_key);
227    }
228
229    /// Clear all session approvals.
230    async fn clear_session_approvals(&self) {
231        let mut approved = self.session_approved.write().await;
232        approved.clear();
233    }
234
235    /// Evaluate a command against the full policy stack.
236    pub async fn evaluate(&self, command: &[String]) -> PolicyEvaluation {
237        let policy = self.policy.read().await;
238        let commands = [command.to_vec()];
239        policy.check_multiple(commands.iter(), &|cmd| self.heuristics_decision(cmd))
240    }
241
242    /// Apply heuristics to determine decision for unknown commands.
243    ///
244    /// Uses the centralized `command_safety` module for dangerous command detection.
245    fn heuristics_decision(&self, command: &[String]) -> Decision {
246        if !self.config.use_heuristics {
247            return Decision::Prompt;
248        }
249
250        if command.is_empty() {
251            return Decision::Prompt;
252        }
253
254        let cmd = &command[0];
255
256        // Known safe read-only commands that can proceed without approval
257        let safe_commands = [
258            "ls", "cat", "head", "tail", "grep", "find", "echo", "pwd", "which", "type", "less", "more", "wc", "sort",
259            "uniq", "diff", "env", "printenv", "hostname", "uname", "date", "whoami", "id", "file", "stat", "tree",
260            "df", "du", "uptime",
261        ];
262
263        if safe_commands.contains(&cmd.as_str()) {
264            return Decision::Allow;
265        }
266
267        // Check dangerous commands using centralized logic
268        if command_might_be_dangerous(command) {
269            // Check for --dry-run flag to allow prompting instead of forbidding
270            if command.iter().any(|arg| arg == "--dry-run" || arg == "-n") {
271                return Decision::Prompt;
272            }
273            return Decision::Forbidden;
274        }
275
276        // For all other commands, default to prompting for approval
277        Decision::Prompt
278    }
279
280    /// Format the reason for requiring approval.
281    fn format_approval_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
282        match rule_match {
283            RuleMatch::PrefixRuleMatch { rule, .. } => {
284                format!(
285                    "Command '{}' matched rule '{}' requiring confirmation",
286                    command.join(" "),
287                    rule.pattern.join(" ")
288                )
289            }
290            RuleMatch::HeuristicsRuleMatch { .. } => {
291                format!("Command '{}' requires confirmation (no explicit policy rule)", command.join(" "))
292            }
293        }
294    }
295
296    /// Format the reason for forbidding a command.
297    fn format_forbidden_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
298        match rule_match {
299            RuleMatch::PrefixRuleMatch { rule, .. } => {
300                format!("Command '{}' is forbidden by rule '{}'", command.join(" "), rule.pattern.join(" "))
301            }
302            RuleMatch::HeuristicsRuleMatch { .. } => {
303                format!("Command '{}' is forbidden by safety heuristics", command.join(" "))
304            }
305        }
306    }
307}
308
309/// Shared reference to an ExecPolicyManager.
310pub type SharedExecPolicyManager = Arc<ExecPolicyManager>;
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use tempfile::tempdir;
316
317    #[tokio::test]
318    async fn test_policy_manager_basic() {
319        let dir = tempdir().unwrap();
320        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
321
322        // Add a rule
323        manager
324            .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
325            .await
326            .unwrap();
327
328        // Check approval
329        let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
330        assert!(result.can_proceed());
331
332        // Unknown command should need approval
333        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
334        assert!(result.requires_approval());
335    }
336
337    #[tokio::test]
338    async fn test_prompt_conflict_with_never_policy_forbids() {
339        let dir = tempdir().unwrap();
340        let manager = ExecPolicyManager::new(
341            dir.path().to_path_buf(),
342            ExecPolicyConfig {
343                default_approval: AskForApproval::Never,
344                ..ExecPolicyConfig::default()
345            },
346        );
347
348        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
349        assert_eq!(result, ExecApprovalRequirement::forbidden(PROMPT_CONFLICT_REASON));
350    }
351
352    #[tokio::test]
353    async fn test_reject_rules_policy_forbids_rule_prompt() {
354        let dir = tempdir().unwrap();
355        let manager = ExecPolicyManager::new(
356            dir.path().to_path_buf(),
357            ExecPolicyConfig {
358                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
359                    sandbox_approval: false,
360                    rules: true,
361                    request_permissions: false,
362                    mcp_elicitations: false,
363                }),
364                ..ExecPolicyConfig::default()
365            },
366        );
367        manager
368            .add_prefix_rule(&["git".to_string()], Decision::Prompt)
369            .await
370            .expect("add prompt rule");
371
372        let result = manager.check_approval(&["git".to_string()]).await;
373        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_RULES_APPROVAL_REASON));
374    }
375
376    #[tokio::test]
377    async fn test_reject_sandbox_policy_forbids_non_rule_prompt() {
378        let dir = tempdir().unwrap();
379        let manager = ExecPolicyManager::new(
380            dir.path().to_path_buf(),
381            ExecPolicyConfig {
382                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
383                    sandbox_approval: true,
384                    rules: false,
385                    request_permissions: false,
386                    mcp_elicitations: false,
387                }),
388                ..ExecPolicyConfig::default()
389            },
390        );
391
392        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
393        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_SANDBOX_APPROVAL_REASON));
394    }
395
396    #[tokio::test]
397    async fn test_trusted_patterns() {
398        let dir = tempdir().unwrap();
399        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
400
401        // Add trusted pattern
402        let amendment = ExecPolicyAmendment::from_prefix("cargo");
403        manager.add_trusted_pattern(amendment).await;
404
405        // Check any cargo command
406        let result = manager.check_approval(&["cargo".to_string(), "test".to_string()]).await;
407        assert!(result.can_proceed());
408    }
409
410    #[tokio::test]
411    async fn test_session_approval() {
412        let dir = tempdir().unwrap();
413        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
414
415        let cmd = vec!["git".to_string(), "status".to_string()];
416
417        // Initially needs approval
418        let result = manager.check_approval(&cmd).await;
419        assert!(result.requires_approval());
420
421        // Approve it
422        manager.approve_command(&cmd).await;
423
424        // Now it should skip
425        let result = manager.check_approval(&cmd).await;
426        assert!(result.can_proceed());
427
428        // Clear approvals
429        manager.clear_session_approvals().await;
430
431        // Needs approval again
432        let result = manager.check_approval(&cmd).await;
433        assert!(result.requires_approval());
434    }
435
436    #[tokio::test]
437    async fn test_heuristics() {
438        let dir = tempdir().unwrap();
439        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
440
441        // Safe command
442        let result = manager.check_approval(&["ls".to_string()]).await;
443        assert!(result.can_proceed());
444
445        // Dangerous command (rm)
446        let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
447        assert!(result.is_forbidden());
448    }
449}