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    /// Load rule files as precedence layers; `layers[0]` wins over later
130    /// entries when both define the same pattern. Missing files are skipped.
131    /// Returns the number of files actually loaded.
132    pub async fn load_policy_layers(&self, layers: &[PathBuf]) -> Result<usize> {
133        let parser = super::parser::PolicyParser::new();
134        let mut merged = Policy::empty();
135        let mut loaded = 0usize;
136        for layer in layers {
137            if !layer.is_file() {
138                continue;
139            }
140            let layer_policy = parser
141                .load_file(layer)
142                .await
143                .with_context(|| format!("Failed to load policy layer {}", layer.display()))?;
144            merged.prepend_layer(layer_policy.rules().iter().cloned());
145            loaded += 1;
146        }
147        let mut policy = self.policy.write().await;
148        *policy = merged;
149        Ok(loaded)
150    }
151
152    /// Add a prefix rule to the policy.
153    async fn add_prefix_rule(&self, pattern: &[String], decision: Decision) -> Result<()> {
154        let mut policy = self.policy.write().await;
155        policy.add_prefix_rule(pattern, decision)
156    }
157
158    /// Add a trusted pattern amendment.
159    async fn add_trusted_pattern(&self, amendment: ExecPolicyAmendment) {
160        let mut patterns = self.trusted_patterns.write().await;
161        patterns.push(amendment);
162    }
163
164    /// Set the sandbox policy.
165    pub async fn set_sandbox_policy(&self, policy: SandboxPolicy) {
166        let mut sandbox = self.sandbox_policy.write().await;
167        *sandbox = policy;
168    }
169
170    /// Get the current sandbox policy.
171    pub async fn sandbox_policy(&self) -> SandboxPolicy {
172        self.sandbox_policy.read().await.clone()
173    }
174
175    /// Check if a command requires approval.
176    async fn check_approval(&self, command: &[String]) -> ExecApprovalRequirement {
177        // Check if already approved this session
178        let command_key = command.join(" ");
179        {
180            let approved = self.session_approved.read().await;
181            if approved.contains(&command_key) {
182                return ExecApprovalRequirement::skip();
183            }
184        }
185
186        // Check trusted patterns
187        {
188            let patterns = self.trusted_patterns.read().await;
189            for pattern in patterns.iter() {
190                if pattern.matches(command) {
191                    return ExecApprovalRequirement::skip();
192                }
193            }
194        }
195
196        // Check policy rules
197        let policy = self.policy.read().await;
198        let rule_match = policy.check(command);
199
200        // Apply heuristics for non-policy matches
201        let decision = match &rule_match {
202            RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
203            RuleMatch::HeuristicsRuleMatch { .. } => self.heuristics_decision(command),
204        };
205
206        match decision {
207            Decision::Allow => ExecApprovalRequirement::skip(),
208            Decision::Prompt => {
209                let prompt_is_rule =
210                    matches!(rule_match, RuleMatch::PrefixRuleMatch { decision: Decision::Prompt, .. });
211
212                match prompt_is_rejected_by_policy(self.config.default_approval, prompt_is_rule) {
213                    Some(reason) => ExecApprovalRequirement::forbidden(reason),
214                    None => ExecApprovalRequirement::needs_approval(self.format_approval_reason(command, &rule_match)),
215                }
216            }
217            Decision::Forbidden => {
218                ExecApprovalRequirement::forbidden(self.format_forbidden_reason(command, &rule_match))
219            }
220        }
221    }
222
223    /// Check multiple commands and return combined approval requirement.
224    pub async fn check_approval_batch(&self, commands: &[Vec<String>]) -> ExecApprovalRequirement {
225        let mut needs_approval_flag = false;
226        let mut reasons = Vec::new();
227
228        for command in commands {
229            let approval = self.check_approval(command).await;
230            if approval.is_forbidden() {
231                return approval;
232            }
233            if approval.requires_approval() {
234                needs_approval_flag = true;
235                if let ExecApprovalRequirement::NeedsApproval { reason: Some(r), .. } = &approval {
236                    reasons.push(r.clone());
237                }
238            }
239        }
240
241        if needs_approval_flag {
242            ExecApprovalRequirement::needs_approval(reasons.join("; "))
243        } else {
244            ExecApprovalRequirement::skip()
245        }
246    }
247
248    /// Mark a command as approved for this session.
249    async fn approve_command(&self, command: &[String]) {
250        let command_key = command.join(" ");
251        let mut approved = self.session_approved.write().await;
252        let _inserted = approved.insert(command_key);
253    }
254
255    /// Clear all session approvals.
256    async fn clear_session_approvals(&self) {
257        let mut approved = self.session_approved.write().await;
258        approved.clear();
259    }
260
261    /// Evaluate a command against the full policy stack.
262    pub async fn evaluate(&self, command: &[String]) -> PolicyEvaluation {
263        let policy = self.policy.read().await;
264        let commands = [command.to_vec()];
265        policy.check_multiple(commands.iter(), &|cmd| self.heuristics_decision(cmd))
266    }
267
268    /// Apply heuristics to determine decision for unknown commands.
269    ///
270    /// Uses the centralized `command_safety` module for dangerous command detection.
271    fn heuristics_decision(&self, command: &[String]) -> Decision {
272        if !self.config.use_heuristics {
273            return Decision::Prompt;
274        }
275
276        if command.is_empty() {
277            return Decision::Prompt;
278        }
279
280        let Some(cmd) = command.first() else {
281            return Decision::Prompt;
282        };
283
284        // Known safe read-only commands that can proceed without approval
285        let safe_commands = [
286            "ls", "cat", "head", "tail", "grep", "find", "echo", "pwd", "which", "type", "less", "more", "wc", "sort",
287            "uniq", "diff", "env", "printenv", "hostname", "uname", "date", "whoami", "id", "file", "stat", "tree",
288            "df", "du", "uptime",
289        ];
290
291        if safe_commands.contains(&cmd.as_str()) {
292            return Decision::Allow;
293        }
294
295        // Check dangerous commands using centralized logic
296        if command_might_be_dangerous(command) {
297            // Check for --dry-run flag to allow prompting instead of forbidding
298            if command.iter().any(|arg| arg == "--dry-run" || arg == "-n") {
299                return Decision::Prompt;
300            }
301            return Decision::Forbidden;
302        }
303
304        // For all other commands, default to prompting for approval
305        Decision::Prompt
306    }
307
308    /// Format the reason for requiring approval.
309    fn format_approval_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
310        match rule_match {
311            RuleMatch::PrefixRuleMatch { rule, .. } => {
312                format!(
313                    "Command '{}' matched rule '{}' requiring confirmation",
314                    command.join(" "),
315                    rule.pattern.join(" ")
316                )
317            }
318            RuleMatch::HeuristicsRuleMatch { .. } => {
319                format!("Command '{}' requires confirmation (no explicit policy rule)", command.join(" "))
320            }
321        }
322    }
323
324    /// Format the reason for forbidding a command.
325    fn format_forbidden_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
326        match rule_match {
327            RuleMatch::PrefixRuleMatch { rule, .. } => {
328                format!("Command '{}' is forbidden by rule '{}'", command.join(" "), rule.pattern.join(" "))
329            }
330            RuleMatch::HeuristicsRuleMatch { .. } => {
331                format!("Command '{}' is forbidden by safety heuristics", command.join(" "))
332            }
333        }
334    }
335}
336
337/// Shared reference to an ExecPolicyManager.
338pub type SharedExecPolicyManager = Arc<ExecPolicyManager>;
339
340/// Canonical rule-file layers for `workspace_root`, highest precedence first:
341/// `<workspace>/.vtcode/rules/*.rules` (project rules) over
342/// `~/.vtcode/rules/*.rules` (user rules). Files within a directory load in
343/// sorted order; missing directories yield no layers.
344pub fn rules_layers(workspace_root: &Path) -> Vec<PathBuf> {
345    fn dir_layers(dir: PathBuf) -> Vec<PathBuf> {
346        let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
347            .into_iter()
348            .flatten()
349            .flatten()
350            .map(|entry| entry.path())
351            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("rules"))
352            .collect();
353        files.sort();
354        files
355    }
356
357    let mut layers = dir_layers(workspace_root.join(".vtcode").join("rules"));
358    if let Some(home) = dirs::home_dir() {
359        layers.extend(dir_layers(home.join(".vtcode").join("rules")));
360    }
361    layers
362}
363
364/// Create a shared manager with the default rule layers for `workspace_root`
365/// auto-loaded (see [`rules_layers`]). Missing rule directories are fine;
366/// errors from unreadable or malformed files propagate.
367pub async fn shared_exec_policy_manager_with_rules(workspace_root: &Path) -> Result<SharedExecPolicyManager> {
368    let manager = Arc::new(ExecPolicyManager::with_defaults(workspace_root.to_path_buf()));
369    let _loaded = manager.load_policy_layers(&rules_layers(workspace_root)).await?;
370    Ok(manager)
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376    use tempfile::tempdir;
377
378    #[tokio::test]
379    async fn test_policy_manager_basic() {
380        let dir = tempdir().unwrap();
381        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
382
383        // Add a rule
384        manager
385            .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
386            .await
387            .unwrap();
388
389        // Check approval
390        let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
391        assert!(result.can_proceed());
392
393        // Unknown command should need approval
394        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
395        assert!(result.requires_approval());
396    }
397
398    #[tokio::test]
399    async fn load_policy_layers_first_layer_wins_on_duplicate_patterns() {
400        let workspace = tempdir().unwrap();
401        let ws_rules = workspace.path().join(".vtcode").join("rules");
402        std::fs::create_dir_all(&ws_rules).unwrap();
403        std::fs::write(ws_rules.join("project.rules"), "allow: git push\n").unwrap();
404
405        let user = tempdir().unwrap();
406        let user_rules = user.path().join("rules");
407        std::fs::create_dir_all(&user_rules).unwrap();
408        std::fs::write(user_rules.join("user.rules"), "prompt: git push\nforbidden: rm\n").unwrap();
409
410        let manager = ExecPolicyManager::with_defaults(workspace.path().to_path_buf());
411        let layers = vec![ws_rules.join("project.rules"), user_rules.join("user.rules")];
412        let loaded = manager.load_policy_layers(&layers).await.unwrap();
413        assert_eq!(loaded, 2);
414
415        // The workspace layer's `allow` wins over the user layer's `prompt`
416        // for the same pattern.
417        let result = manager.check_approval(&["git".to_string(), "push".to_string()]).await;
418        assert!(result.can_proceed());
419
420        // Patterns only present in the lower-precedence layer still apply.
421        let result = manager
422            .check_approval(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
423            .await;
424        assert!(!result.can_proceed());
425    }
426
427    #[tokio::test]
428    async fn load_policy_layers_skips_missing_files() {
429        let dir = tempdir().unwrap();
430        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
431        let loaded = manager
432            .load_policy_layers(&[dir.path().join("does-not-exist.rules")])
433            .await
434            .unwrap();
435        assert_eq!(loaded, 0);
436    }
437
438    #[test]
439    fn rules_layers_lists_workspace_files_sorted_before_user_layers() {
440        let workspace = tempdir().unwrap();
441        let ws_rules = workspace.path().join(".vtcode").join("rules");
442        std::fs::create_dir_all(&ws_rules).unwrap();
443        std::fs::write(ws_rules.join("b.rules"), "").unwrap();
444        std::fs::write(ws_rules.join("a.rules"), "").unwrap();
445        std::fs::write(ws_rules.join("ignored.txt"), "").unwrap();
446
447        let layers = rules_layers(workspace.path());
448        // Workspace rules come first (highest precedence), sorted; non-`.rules`
449        // files are excluded. User layers (if any exist under $HOME) come after.
450        let workspace_count = layers
451            .iter()
452            .take_while(|p| p.starts_with(ws_rules.join("a.rules").parent().unwrap()))
453            .count();
454        assert!(workspace_count >= 2, "expected workspace rules layers, got {layers:?}");
455        assert_eq!(layers[0].file_name().unwrap(), "a.rules");
456        assert_eq!(layers[1].file_name().unwrap(), "b.rules");
457        assert!(layers.iter().all(|p| p.extension().and_then(|e| e.to_str()) == Some("rules")));
458    }
459
460    #[tokio::test]
461    async fn test_prompt_conflict_with_never_policy_forbids() {
462        let dir = tempdir().unwrap();
463        let manager = ExecPolicyManager::new(
464            dir.path().to_path_buf(),
465            ExecPolicyConfig {
466                default_approval: AskForApproval::Never,
467                ..ExecPolicyConfig::default()
468            },
469        );
470
471        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
472        assert_eq!(result, ExecApprovalRequirement::forbidden(PROMPT_CONFLICT_REASON));
473    }
474
475    #[tokio::test]
476    async fn test_reject_rules_policy_forbids_rule_prompt() {
477        let dir = tempdir().unwrap();
478        let manager = ExecPolicyManager::new(
479            dir.path().to_path_buf(),
480            ExecPolicyConfig {
481                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
482                    sandbox_approval: false,
483                    rules: true,
484                    request_permissions: false,
485                    mcp_elicitations: false,
486                }),
487                ..ExecPolicyConfig::default()
488            },
489        );
490        manager
491            .add_prefix_rule(&["git".to_string()], Decision::Prompt)
492            .await
493            .expect("add prompt rule");
494
495        let result = manager.check_approval(&["git".to_string()]).await;
496        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_RULES_APPROVAL_REASON));
497    }
498
499    #[tokio::test]
500    async fn test_reject_sandbox_policy_forbids_non_rule_prompt() {
501        let dir = tempdir().unwrap();
502        let manager = ExecPolicyManager::new(
503            dir.path().to_path_buf(),
504            ExecPolicyConfig {
505                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
506                    sandbox_approval: true,
507                    rules: false,
508                    request_permissions: false,
509                    mcp_elicitations: false,
510                }),
511                ..ExecPolicyConfig::default()
512            },
513        );
514
515        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
516        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_SANDBOX_APPROVAL_REASON));
517    }
518
519    #[tokio::test]
520    async fn test_trusted_patterns() {
521        let dir = tempdir().unwrap();
522        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
523
524        // Add trusted pattern
525        let amendment = ExecPolicyAmendment::from_prefix("cargo");
526        manager.add_trusted_pattern(amendment).await;
527
528        // Check any cargo command
529        let result = manager.check_approval(&["cargo".to_string(), "test".to_string()]).await;
530        assert!(result.can_proceed());
531    }
532
533    #[tokio::test]
534    async fn test_session_approval() {
535        let dir = tempdir().unwrap();
536        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
537
538        let cmd = vec!["git".to_string(), "status".to_string()];
539
540        // Initially needs approval
541        let result = manager.check_approval(&cmd).await;
542        assert!(result.requires_approval());
543
544        // Approve it
545        manager.approve_command(&cmd).await;
546
547        // Now it should skip
548        let result = manager.check_approval(&cmd).await;
549        assert!(result.can_proceed());
550
551        // Clear approvals
552        manager.clear_session_approvals().await;
553
554        // Needs approval again
555        let result = manager.check_approval(&cmd).await;
556        assert!(result.requires_approval());
557    }
558
559    #[tokio::test]
560    async fn test_heuristics() {
561        let dir = tempdir().unwrap();
562        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
563
564        // Safe command
565        let result = manager.check_approval(&["ls".to_string()]).await;
566        assert!(result.can_proceed());
567
568        // Dangerous command (rm)
569        let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
570        assert!(result.is_forbidden());
571    }
572}