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 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 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 manager
417 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
418 .await
419 .unwrap();
420
421 let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
423 assert!(result.can_proceed());
424
425 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 let result = manager.check_approval(&["git".to_string(), "push".to_string()]).await;
450 assert!(result.can_proceed());
451
452 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 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 let amendment = ExecPolicyAmendment::from_prefix("cargo");
558 manager.add_trusted_pattern(amendment).await;
559
560 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 let result = manager.check_approval(&cmd).await;
574 assert!(result.requires_approval());
575
576 manager.approve_command(&cmd).await;
578
579 let result = manager.check_approval(&cmd).await;
581 assert!(result.can_proceed());
582
583 manager.clear_session_approvals().await;
585
586 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 let result = manager.check_approval(&["ls".to_string()]).await;
598 assert!(result.can_proceed());
599
600 let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
602 assert!(result.is_forbidden());
603 }
604}