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 invalid_reload_preserves_active_policy() {
380        let dir = tempdir().unwrap();
381        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
382        let active = dir.path().join("active.rules");
383        let valid = dir.path().join("valid.rules");
384        let invalid = dir.path().join("invalid.rules");
385        std::fs::write(&active, "forbidden: git\nallow: custom").unwrap();
386        std::fs::write(&valid, "allow: git\nforbidden: custom").unwrap();
387        std::fs::write(&invalid, "allow: echo\nallow: ").unwrap();
388        manager.load_policy(&active).await.unwrap();
389        let before = manager.policy.read().await.rules().to_vec();
390        assert!(manager.load_policy(&invalid).await.is_err());
391        assert_eq!(manager.policy.read().await.rules(), before);
392        assert!(manager.load_policy_layers(&[valid, invalid]).await.is_err());
393        assert_eq!(manager.policy.read().await.rules(), before);
394        assert!(manager.check_approval(&["git".into()]).await.is_forbidden());
395        assert!(manager.check_approval(&["custom".into()]).await.can_proceed());
396    }
397
398    #[tokio::test]
399    async fn empty_trusted_amendment_cannot_bypass_policy() {
400        let dir = tempdir().unwrap();
401        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
402        let amendment = serde_json::from_str(r#"{"pattern":[]}"#).unwrap();
403        manager.add_trusted_pattern(amendment).await;
404        manager.add_prefix_rule(&["blocked".into()], Decision::Forbidden).await.unwrap();
405        assert!(manager.check_approval(&[]).await.requires_approval());
406        assert!(manager.check_approval(&["unknown".into()]).await.requires_approval());
407        assert!(manager.check_approval(&["blocked".into()]).await.is_forbidden());
408    }
409
410    #[tokio::test]
411    async fn test_policy_manager_basic() {
412        let dir = tempdir().unwrap();
413        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
414
415        // Add a rule
416        manager
417            .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
418            .await
419            .unwrap();
420
421        // Check approval
422        let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
423        assert!(result.can_proceed());
424
425        // Unknown command should need approval
426        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
427        assert!(result.requires_approval());
428    }
429
430    #[tokio::test]
431    async fn load_policy_layers_first_layer_wins_on_duplicate_patterns() {
432        let workspace = tempdir().unwrap();
433        let ws_rules = workspace.path().join(".vtcode").join("rules");
434        std::fs::create_dir_all(&ws_rules).unwrap();
435        std::fs::write(ws_rules.join("project.rules"), "allow: git push\n").unwrap();
436
437        let user = tempdir().unwrap();
438        let user_rules = user.path().join("rules");
439        std::fs::create_dir_all(&user_rules).unwrap();
440        std::fs::write(user_rules.join("user.rules"), "prompt: git push\nforbidden: rm\n").unwrap();
441
442        let manager = ExecPolicyManager::with_defaults(workspace.path().to_path_buf());
443        let layers = vec![ws_rules.join("project.rules"), user_rules.join("user.rules")];
444        let loaded = manager.load_policy_layers(&layers).await.unwrap();
445        assert_eq!(loaded, 2);
446
447        // The workspace layer's `allow` wins over the user layer's `prompt`
448        // for the same pattern.
449        let result = manager.check_approval(&["git".to_string(), "push".to_string()]).await;
450        assert!(result.can_proceed());
451
452        // Patterns only present in the lower-precedence layer still apply.
453        let result = manager
454            .check_approval(&["rm".to_string(), "-rf".to_string(), "/".to_string()])
455            .await;
456        assert!(!result.can_proceed());
457    }
458
459    #[tokio::test]
460    async fn load_policy_layers_skips_missing_files() {
461        let dir = tempdir().unwrap();
462        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
463        let loaded = manager
464            .load_policy_layers(&[dir.path().join("does-not-exist.rules")])
465            .await
466            .unwrap();
467        assert_eq!(loaded, 0);
468    }
469
470    #[test]
471    fn rules_layers_lists_workspace_files_sorted_before_user_layers() {
472        let workspace = tempdir().unwrap();
473        let ws_rules = workspace.path().join(".vtcode").join("rules");
474        std::fs::create_dir_all(&ws_rules).unwrap();
475        std::fs::write(ws_rules.join("b.rules"), "").unwrap();
476        std::fs::write(ws_rules.join("a.rules"), "").unwrap();
477        std::fs::write(ws_rules.join("ignored.txt"), "").unwrap();
478
479        let layers = rules_layers(workspace.path());
480        // Workspace rules come first (highest precedence), sorted; non-`.rules`
481        // files are excluded. User layers (if any exist under $HOME) come after.
482        let workspace_count = layers
483            .iter()
484            .take_while(|p| p.starts_with(ws_rules.join("a.rules").parent().unwrap()))
485            .count();
486        assert!(workspace_count >= 2, "expected workspace rules layers, got {layers:?}");
487        assert_eq!(layers[0].file_name().unwrap(), "a.rules");
488        assert_eq!(layers[1].file_name().unwrap(), "b.rules");
489        assert!(layers.iter().all(|p| p.extension().and_then(|e| e.to_str()) == Some("rules")));
490    }
491
492    #[tokio::test]
493    async fn test_prompt_conflict_with_never_policy_forbids() {
494        let dir = tempdir().unwrap();
495        let manager = ExecPolicyManager::new(
496            dir.path().to_path_buf(),
497            ExecPolicyConfig {
498                default_approval: AskForApproval::Never,
499                ..ExecPolicyConfig::default()
500            },
501        );
502
503        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
504        assert_eq!(result, ExecApprovalRequirement::forbidden(PROMPT_CONFLICT_REASON));
505    }
506
507    #[tokio::test]
508    async fn test_reject_rules_policy_forbids_rule_prompt() {
509        let dir = tempdir().unwrap();
510        let manager = ExecPolicyManager::new(
511            dir.path().to_path_buf(),
512            ExecPolicyConfig {
513                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
514                    sandbox_approval: false,
515                    rules: true,
516                    request_permissions: false,
517                    mcp_elicitations: false,
518                }),
519                ..ExecPolicyConfig::default()
520            },
521        );
522        manager
523            .add_prefix_rule(&["git".to_string()], Decision::Prompt)
524            .await
525            .expect("add prompt rule");
526
527        let result = manager.check_approval(&["git".to_string()]).await;
528        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_RULES_APPROVAL_REASON));
529    }
530
531    #[tokio::test]
532    async fn test_reject_sandbox_policy_forbids_non_rule_prompt() {
533        let dir = tempdir().unwrap();
534        let manager = ExecPolicyManager::new(
535            dir.path().to_path_buf(),
536            ExecPolicyConfig {
537                default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
538                    sandbox_approval: true,
539                    rules: false,
540                    request_permissions: false,
541                    mcp_elicitations: false,
542                }),
543                ..ExecPolicyConfig::default()
544            },
545        );
546
547        let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
548        assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_SANDBOX_APPROVAL_REASON));
549    }
550
551    #[tokio::test]
552    async fn test_trusted_patterns() {
553        let dir = tempdir().unwrap();
554        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
555
556        // Add trusted pattern
557        let amendment = ExecPolicyAmendment::from_prefix("cargo");
558        manager.add_trusted_pattern(amendment).await;
559
560        // Check any cargo command
561        let result = manager.check_approval(&["cargo".to_string(), "test".to_string()]).await;
562        assert!(result.can_proceed());
563    }
564
565    #[tokio::test]
566    async fn test_session_approval() {
567        let dir = tempdir().unwrap();
568        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
569
570        let cmd = vec!["git".to_string(), "status".to_string()];
571
572        // Initially needs approval
573        let result = manager.check_approval(&cmd).await;
574        assert!(result.requires_approval());
575
576        // Approve it
577        manager.approve_command(&cmd).await;
578
579        // Now it should skip
580        let result = manager.check_approval(&cmd).await;
581        assert!(result.can_proceed());
582
583        // Clear approvals
584        manager.clear_session_approvals().await;
585
586        // Needs approval again
587        let result = manager.check_approval(&cmd).await;
588        assert!(result.requires_approval());
589    }
590
591    #[tokio::test]
592    async fn test_heuristics() {
593        let dir = tempdir().unwrap();
594        let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
595
596        // Safe command
597        let result = manager.check_approval(&["ls".to_string()]).await;
598        assert!(result.can_proceed());
599
600        // Dangerous command (rm)
601        let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
602        assert!(result.is_forbidden());
603    }
604}