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(
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 session_approved: RwLock<HashSet<String>>,
99}
100
101impl ExecPolicyManager {
102 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 fn with_defaults(workspace_root: PathBuf) -> Self {
116 Self::new(workspace_root, ExecPolicyConfig::default())
117 }
118
119 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 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 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 async fn add_trusted_pattern(&self, amendment: ExecPolicyAmendment) {
160 let mut patterns = self.trusted_patterns.write().await;
161 patterns.push(amendment);
162 }
163
164 pub async fn set_sandbox_policy(&self, policy: SandboxPolicy) {
166 let mut sandbox = self.sandbox_policy.write().await;
167 *sandbox = policy;
168 }
169
170 pub async fn sandbox_policy(&self) -> SandboxPolicy {
172 self.sandbox_policy.read().await.clone()
173 }
174
175 async fn check_approval(&self, command: &[String]) -> ExecApprovalRequirement {
177 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 {
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 let policy = self.policy.read().await;
198 let rule_match = policy.check(command);
199
200 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 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 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 async fn clear_session_approvals(&self) {
257 let mut approved = self.session_approved.write().await;
258 approved.clear();
259 }
260
261 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 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 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 if command_might_be_dangerous(command) {
297 if command.iter().any(|arg| arg == "--dry-run" || arg == "-n") {
299 return Decision::Prompt;
300 }
301 return Decision::Forbidden;
302 }
303
304 Decision::Prompt
306 }
307
308 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 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
337pub type SharedExecPolicyManager = Arc<ExecPolicyManager>;
339
340pub 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
364pub 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 manager
385 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
386 .await
387 .unwrap();
388
389 let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
391 assert!(result.can_proceed());
392
393 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 let result = manager.check_approval(&["git".to_string(), "push".to_string()]).await;
418 assert!(result.can_proceed());
419
420 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 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 let amendment = ExecPolicyAmendment::from_prefix("cargo");
526 manager.add_trusted_pattern(amendment).await;
527
528 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 let result = manager.check_approval(&cmd).await;
542 assert!(result.requires_approval());
543
544 manager.approve_command(&cmd).await;
546
547 let result = manager.check_approval(&cmd).await;
549 assert!(result.can_proceed());
550
551 manager.clear_session_approvals().await;
553
554 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 let result = manager.check_approval(&["ls".to_string()]).await;
566 assert!(result.can_proceed());
567
568 let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
570 assert!(result.is_forbidden());
571 }
572}