vtcode_safety/exec_policy/
manager.rs1use 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#[derive(Debug, Clone)]
51pub struct ExecPolicyConfig {
52 default_sandbox_policy: SandboxPolicy,
54
55 default_approval: AskForApproval,
57
58 use_heuristics: bool,
60
61 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
76pub struct ExecPolicyManager {
78 policy: RwLock<Policy>,
80
81 trusted_patterns: RwLock<Vec<ExecPolicyAmendment>>,
83
84 sandbox_policy: RwLock<SandboxPolicy>,
86
87 config: ExecPolicyConfig,
89
90 #[expect(dead_code)]
92 workspace_root: PathBuf,
93
94 session_approved: RwLock<HashSet<String>>,
96}
97
98impl ExecPolicyManager {
99 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 fn with_defaults(workspace_root: PathBuf) -> Self {
113 Self::new(workspace_root, ExecPolicyConfig::default())
114 }
115
116 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 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 async fn add_trusted_pattern(&self, amendment: ExecPolicyAmendment) {
134 let mut patterns = self.trusted_patterns.write().await;
135 patterns.push(amendment);
136 }
137
138 pub async fn set_sandbox_policy(&self, policy: SandboxPolicy) {
140 let mut sandbox = self.sandbox_policy.write().await;
141 *sandbox = policy;
142 }
143
144 pub async fn sandbox_policy(&self) -> SandboxPolicy {
146 self.sandbox_policy.read().await.clone()
147 }
148
149 async fn check_approval(&self, command: &[String]) -> ExecApprovalRequirement {
151 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 {
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 let policy = self.policy.read().await;
172 let rule_match = policy.check(command);
173
174 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 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 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 async fn clear_session_approvals(&self) {
231 let mut approved = self.session_approved.write().await;
232 approved.clear();
233 }
234
235 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 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 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 if command_might_be_dangerous(command) {
269 if command.iter().any(|arg| arg == "--dry-run" || arg == "-n") {
271 return Decision::Prompt;
272 }
273 return Decision::Forbidden;
274 }
275
276 Decision::Prompt
278 }
279
280 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 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
309pub 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 manager
324 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
325 .await
326 .unwrap();
327
328 let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
330 assert!(result.can_proceed());
331
332 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 let amendment = ExecPolicyAmendment::from_prefix("cargo");
403 manager.add_trusted_pattern(amendment).await;
404
405 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 let result = manager.check_approval(&cmd).await;
419 assert!(result.requires_approval());
420
421 manager.approve_command(&cmd).await;
423
424 let result = manager.check_approval(&cmd).await;
426 assert!(result.can_proceed());
427
428 manager.clear_session_approvals().await;
430
431 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 let result = manager.check_approval(&["ls".to_string()]).await;
443 assert!(result.can_proceed());
444
445 let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
447 assert!(result.is_forbidden());
448 }
449}