1use 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 policy.check_multiple(std::iter::once(command), &|cmd| self.heuristics_decision(cmd))
265 }
266
267 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 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 if command_might_be_dangerous(command) {
296 if command.iter().any(|arg| arg == "--dry-run" || arg == "-n") {
298 return Decision::Prompt;
299 }
300 return Decision::Forbidden;
301 }
302
303 Decision::Prompt
305 }
306
307 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 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
336pub type SharedExecPolicyManager = Arc<ExecPolicyManager>;
338
339pub 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
363pub 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 manager
416 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
417 .await
418 .unwrap();
419
420 let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
422 assert!(result.can_proceed());
423
424 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 let result = manager.check_approval(&["git".to_string(), "push".to_string()]).await;
449 assert!(result.can_proceed());
450
451 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 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 let amendment = ExecPolicyAmendment::from_prefix("cargo");
557 manager.add_trusted_pattern(amendment).await;
558
559 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 let result = manager.check_approval(&cmd).await;
573 assert!(result.requires_approval());
574
575 manager.approve_command(&cmd).await;
577
578 let result = manager.check_approval(&cmd).await;
580 assert!(result.can_proceed());
581
582 manager.clear_session_approvals().await;
584
585 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 let result = manager.check_approval(&["ls".to_string()]).await;
597 assert!(result.can_proceed());
598
599 let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
601 assert!(result.is_forbidden());
602 }
603}