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