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        policy.check_multiple(std::iter::once(command), &|cmd| self.heuristics_decision(cmd))
265    }
266
267    /// Apply heuristics to determine decision for unknown commands.
268    ///
269    /// Uses the centralized `command_safety` module for dangerous command detection.
270    fn heuristics_decision(&self, command: &[String]) -> Decision {
271        if !self.config.use_heuristics {
272            return Decision::Prompt;
273        }
274
275        if command.is_empty() {
276            return Decision::Prompt;
277        }
278
279        let Some(cmd) = command.first() else {
280            return Decision::Prompt;
281        };
282
283        // Known safe read-only commands that can proceed without approval
284        let safe_commands = [
285            "ls", "cat", "head", "tail", "grep", "find", "echo", "pwd", "which", "type", "less", "more", "wc", "sort",
286            "uniq", "diff", "env", "printenv", "hostname", "uname", "date", "whoami", "id", "file", "stat", "tree",
287            "df", "du", "uptime",
288        ];
289
290        if safe_commands.contains(&cmd.as_str()) {
291            return Decision::Allow;
292        }
293
294        // Check dangerous commands using centralized logic
295        if command_might_be_dangerous(command) {
296            // Check for --dry-run flag to allow prompting instead of forbidding
297            if command.iter().any(|arg| arg == "--dry-run" || arg == "-n") {
298                return Decision::Prompt;
299            }
300            return Decision::Forbidden;
301        }
302
303        // For all other commands, default to prompting for approval
304        Decision::Prompt
305    }
306
307    /// Format the reason for requiring approval.
308    fn format_approval_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
309        match rule_match {
310            RuleMatch::PrefixRuleMatch { rule, .. } => {
311                format!(
312                    "Command '{}' matched rule '{}' requiring confirmation",
313                    command.join(" "),
314                    rule.pattern.join(" ")
315                )
316            }
317            RuleMatch::HeuristicsRuleMatch { .. } => {
318                format!("Command '{}' requires confirmation (no explicit policy rule)", command.join(" "))
319            }
320        }
321    }
322
323    /// Format the reason for forbidding a command.
324    fn format_forbidden_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
325        match rule_match {
326            RuleMatch::PrefixRuleMatch { rule, .. } => {
327                format!("Command '{}' is forbidden by rule '{}'", command.join(" "), rule.pattern.join(" "))
328            }
329            RuleMatch::HeuristicsRuleMatch { .. } => {
330                format!("Command '{}' is forbidden by safety heuristics", command.join(" "))
331            }
332        }
333    }
334}
335
336/// Shared reference to an ExecPolicyManager.
337pub type SharedExecPolicyManager = Arc<ExecPolicyManager>;
338
339/// Canonical rule-file layers for `workspace_root`, highest precedence first:
340/// `<workspace>/.vtcode/rules/*.rules` (project rules) over
341/// `~/.vtcode/rules/*.rules` (user rules). Files within a directory load in
342/// sorted order; missing directories yield no layers.
343pub fn rules_layers(workspace_root: &Path) -> Vec<PathBuf> {
344    fn dir_layers(dir: PathBuf) -> Vec<PathBuf> {
345        let mut files: Vec<PathBuf> = std::fs::read_dir(&dir)
346            .into_iter()
347            .flatten()
348            .flatten()
349            .map(|entry| entry.path())
350            .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("rules"))
351            .collect();
352        files.sort();
353        files
354    }
355
356    let mut layers = dir_layers(workspace_root.join(".vtcode").join("rules"));
357    if let Some(home) = dirs::home_dir() {
358        layers.extend(dir_layers(home.join(".vtcode").join("rules")));
359    }
360    layers
361}
362
363/// Create a shared manager with the default rule layers for `workspace_root`
364/// auto-loaded (see [`rules_layers`]). Missing rule directories are fine;
365/// errors from unreadable or malformed files propagate.
366pub async fn shared_exec_policy_manager_with_rules(workspace_root: &Path) -> Result<SharedExecPolicyManager> {
367    let manager = Arc::new(ExecPolicyManager::with_defaults(workspace_root.to_path_buf()));
368    let _loaded = manager.load_policy_layers(&rules_layers(workspace_root)).await?;
369    Ok(manager)
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375    use tempfile::tempdir;
376
377    #[tokio::test]
378    async fn invalid_reload_preserves_active_policy() {
379        let dir = tempdir().unwrap();
380        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
381        let active = dir.path().join("active.rules");
382        let valid = dir.path().join("valid.rules");
383        let invalid = dir.path().join("invalid.rules");
384        std::fs::write(&active, "forbidden: git\nallow: custom").unwrap();
385        std::fs::write(&valid, "allow: git\nforbidden: custom").unwrap();
386        std::fs::write(&invalid, "allow: echo\nallow: ").unwrap();
387        manager.load_policy(&active).await.unwrap();
388        let before = manager.policy.read().await.rules().to_vec();
389        assert!(manager.load_policy(&invalid).await.is_err());
390        assert_eq!(manager.policy.read().await.rules(), before);
391        assert!(manager.load_policy_layers(&[valid, invalid]).await.is_err());
392        assert_eq!(manager.policy.read().await.rules(), before);
393        assert!(manager.check_approval(&["git".into()]).await.is_forbidden());
394        assert!(manager.check_approval(&["custom".into()]).await.can_proceed());
395    }
396
397    #[tokio::test]
398    async fn empty_trusted_amendment_cannot_bypass_policy() {
399        let dir = tempdir().unwrap();
400        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
401        let amendment = serde_json::from_str(r#"{"pattern":[]}"#).unwrap();
402        manager.add_trusted_pattern(amendment).await;
403        manager.add_prefix_rule(&["blocked".into()], Decision::Forbidden).await.unwrap();
404        assert!(manager.check_approval(&[]).await.requires_approval());
405        assert!(manager.check_approval(&["unknown".into()]).await.requires_approval());
406        assert!(manager.check_approval(&["blocked".into()]).await.is_forbidden());
407    }
408
409    #[tokio::test]
410    async fn test_policy_manager_basic() {
411        let dir = tempdir().unwrap();
412        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
413
414        // Add a rule
415        manager
416            .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
417            .await
418            .unwrap();
419
420        // Check approval
421        let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
422        assert!(result.can_proceed());
423
424        // Unknown command should need approval
425        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
426        assert!(result.requires_approval());
427    }
428
429    #[tokio::test]
430    async fn load_policy_layers_first_layer_wins_on_duplicate_patterns() {
431        let workspace = tempdir().unwrap();
432        let ws_rules = workspace.path().join(".vtcode").join("rules");
433        std::fs::create_dir_all(&ws_rules).unwrap();
434        std::fs::write(ws_rules.join("project.rules"), "allow: git push\n").unwrap();
435
436        let user = tempdir().unwrap();
437        let user_rules = user.path().join("rules");
438        std::fs::create_dir_all(&user_rules).unwrap();
439        std::fs::write(user_rules.join("user.rules"), "prompt: git push\nforbidden: rm\n").unwrap();
440
441        let manager = ExecPolicyManager::with_defaults(workspace.path().to_path_buf());
442        let layers = vec![ws_rules.join("project.rules"), user_rules.join("user.rules")];
443        let loaded = manager.load_policy_layers(&layers).await.unwrap();
444        assert_eq!(loaded, 2);
445
446        // The workspace layer's `allow` wins over the user layer's `prompt`
447        // for the same pattern.
448        let result = manager.check_approval(&["git".to_string(), "push".to_string()]).await;
449        assert!(result.can_proceed());
450
451        // Patterns only present in the lower-precedence layer still apply.
452        let result = manager
453            .check_approval(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
454            .await;
455        assert!(!result.can_proceed());
456    }
457
458    #[tokio::test]
459    async fn load_policy_layers_skips_missing_files() {
460        let dir = tempdir().unwrap();
461        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
462        let loaded = manager
463            .load_policy_layers(&[dir.path().join("does-not-exist.rules")])
464            .await
465            .unwrap();
466        assert_eq!(loaded, 0);
467    }
468
469    #[test]
470    fn rules_layers_lists_workspace_files_sorted_before_user_layers() {
471        let workspace = tempdir().unwrap();
472        let ws_rules = workspace.path().join(".vtcode").join("rules");
473        std::fs::create_dir_all(&ws_rules).unwrap();
474        std::fs::write(ws_rules.join("b.rules"), "").unwrap();
475        std::fs::write(ws_rules.join("a.rules"), "").unwrap();
476        std::fs::write(ws_rules.join("ignored.txt"), "").unwrap();
477
478        let layers = rules_layers(workspace.path());
479        // Workspace rules come first (highest precedence), sorted; non-`.rules`
480        // files are excluded. User layers (if any exist under $HOME) come after.
481        let workspace_count = layers
482            .iter()
483            .take_while(|p| p.starts_with(ws_rules.join("a.rules").parent().unwrap()))
484            .count();
485        assert!(workspace_count >= 2, "expected workspace rules layers, got {layers:?}");
486        assert_eq!(layers[0].file_name().unwrap(), "a.rules");
487        assert_eq!(layers[1].file_name().unwrap(), "b.rules");
488        assert!(layers.iter().all(|p| p.extension().and_then(|e| e.to_str()) == Some("rules")));
489    }
490
491    #[tokio::test]
492    async fn test_prompt_conflict_with_never_policy_forbids() {
493        let dir = tempdir().unwrap();
494        let manager = ExecPolicyManager::new(
495            dir.path().to_path_buf(),
496            ExecPolicyConfig {
497                default_approval: AskForApproval::Never,
498                ..ExecPolicyConfig::default()
499            },
500        );
501
502        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
503        assert_eq!(result, ExecApprovalRequirement::forbidden(PROMPT_CONFLICT_REASON));
504    }
505
506    #[tokio::test]
507    async fn test_reject_rules_policy_forbids_rule_prompt() {
508        let dir = tempdir().unwrap();
509        let manager = ExecPolicyManager::new(
510            dir.path().to_path_buf(),
511            ExecPolicyConfig {
512                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
513                    sandbox_approval: false,
514                    rules: true,
515                    request_permissions: false,
516                    mcp_elicitations: false,
517                }),
518                ..ExecPolicyConfig::default()
519            },
520        );
521        manager
522            .add_prefix_rule(&["git".to_string()], Decision::Prompt)
523            .await
524            .expect("add prompt rule");
525
526        let result = manager.check_approval(&["git".to_string()]).await;
527        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_RULES_APPROVAL_REASON));
528    }
529
530    #[tokio::test]
531    async fn test_reject_sandbox_policy_forbids_non_rule_prompt() {
532        let dir = tempdir().unwrap();
533        let manager = ExecPolicyManager::new(
534            dir.path().to_path_buf(),
535            ExecPolicyConfig {
536                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
537                    sandbox_approval: true,
538                    rules: false,
539                    request_permissions: false,
540                    mcp_elicitations: false,
541                }),
542                ..ExecPolicyConfig::default()
543            },
544        );
545
546        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
547        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_SANDBOX_APPROVAL_REASON));
548    }
549
550    #[tokio::test]
551    async fn test_trusted_patterns() {
552        let dir = tempdir().unwrap();
553        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
554
555        // Add trusted pattern
556        let amendment = ExecPolicyAmendment::from_prefix("cargo");
557        manager.add_trusted_pattern(amendment).await;
558
559        // Check any cargo command
560        let result = manager.check_approval(&["cargo".to_string(), "test".to_string()]).await;
561        assert!(result.can_proceed());
562    }
563
564    #[tokio::test]
565    async fn test_session_approval() {
566        let dir = tempdir().unwrap();
567        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
568
569        let cmd = vec!["git".to_string(), "status".to_string()];
570
571        // Initially needs approval
572        let result = manager.check_approval(&cmd).await;
573        assert!(result.requires_approval());
574
575        // Approve it
576        manager.approve_command(&cmd).await;
577
578        // Now it should skip
579        let result = manager.check_approval(&cmd).await;
580        assert!(result.can_proceed());
581
582        // Clear approvals
583        manager.clear_session_approvals().await;
584
585        // Needs approval again
586        let result = manager.check_approval(&cmd).await;
587        assert!(result.requires_approval());
588    }
589
590    #[tokio::test]
591    async fn test_heuristics() {
592        let dir = tempdir().unwrap();
593        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
594
595        // Safe command
596        let result = manager.check_approval(&["ls".to_string()]).await;
597        assert!(result.can_proceed());
598
599        // Dangerous command (rm)
600        let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
601        assert!(result.is_forbidden());
602    }
603}