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 async fn add_prefix_rule(&self, pattern: &[String], decision: Decision) -> Result<()> {
131 let mut policy = self.policy.write().await;
132 policy.add_prefix_rule(pattern, decision)
133 }
134
135 async fn add_trusted_pattern(&self, amendment: ExecPolicyAmendment) {
137 let mut patterns = self.trusted_patterns.write().await;
138 patterns.push(amendment);
139 }
140
141 pub async fn set_sandbox_policy(&self, policy: SandboxPolicy) {
143 let mut sandbox = self.sandbox_policy.write().await;
144 *sandbox = policy;
145 }
146
147 pub async fn sandbox_policy(&self) -> SandboxPolicy {
149 self.sandbox_policy.read().await.clone()
150 }
151
152 async fn check_approval(&self, command: &[String]) -> ExecApprovalRequirement {
154 let command_key = command.join(" ");
156 {
157 let approved = self.session_approved.read().await;
158 if approved.contains(&command_key) {
159 return ExecApprovalRequirement::skip();
160 }
161 }
162
163 {
165 let patterns = self.trusted_patterns.read().await;
166 for pattern in patterns.iter() {
167 if pattern.matches(command) {
168 return ExecApprovalRequirement::skip();
169 }
170 }
171 }
172
173 let policy = self.policy.read().await;
175 let rule_match = policy.check(command);
176
177 let decision = match &rule_match {
179 RuleMatch::PrefixRuleMatch { decision, .. } => *decision,
180 RuleMatch::HeuristicsRuleMatch { .. } => self.heuristics_decision(command),
181 };
182
183 match decision {
184 Decision::Allow => ExecApprovalRequirement::skip(),
185 Decision::Prompt => {
186 let prompt_is_rule =
187 matches!(rule_match, RuleMatch::PrefixRuleMatch { decision: Decision::Prompt, .. });
188
189 match prompt_is_rejected_by_policy(self.config.default_approval, prompt_is_rule) {
190 Some(reason) => ExecApprovalRequirement::forbidden(reason),
191 None => ExecApprovalRequirement::needs_approval(self.format_approval_reason(command, &rule_match)),
192 }
193 }
194 Decision::Forbidden => {
195 ExecApprovalRequirement::forbidden(self.format_forbidden_reason(command, &rule_match))
196 }
197 }
198 }
199
200 pub async fn check_approval_batch(&self, commands: &[Vec<String>]) -> ExecApprovalRequirement {
202 let mut needs_approval_flag = false;
203 let mut reasons = Vec::new();
204
205 for command in commands {
206 let approval = self.check_approval(command).await;
207 if approval.is_forbidden() {
208 return approval;
209 }
210 if approval.requires_approval() {
211 needs_approval_flag = true;
212 if let ExecApprovalRequirement::NeedsApproval { reason: Some(r), .. } = &approval {
213 reasons.push(r.clone());
214 }
215 }
216 }
217
218 if needs_approval_flag {
219 ExecApprovalRequirement::needs_approval(reasons.join("; "))
220 } else {
221 ExecApprovalRequirement::skip()
222 }
223 }
224
225 async fn approve_command(&self, command: &[String]) {
227 let command_key = command.join(" ");
228 let mut approved = self.session_approved.write().await;
229 let _inserted = approved.insert(command_key);
230 }
231
232 async fn clear_session_approvals(&self) {
234 let mut approved = self.session_approved.write().await;
235 approved.clear();
236 }
237
238 pub async fn evaluate(&self, command: &[String]) -> PolicyEvaluation {
240 let policy = self.policy.read().await;
241 let commands = [command.to_vec()];
242 policy.check_multiple(commands.iter(), &|cmd| self.heuristics_decision(cmd))
243 }
244
245 fn heuristics_decision(&self, command: &[String]) -> Decision {
249 if !self.config.use_heuristics {
250 return Decision::Prompt;
251 }
252
253 if command.is_empty() {
254 return Decision::Prompt;
255 }
256
257 let Some(cmd) = command.first() else {
258 return Decision::Prompt;
259 };
260
261 let safe_commands = [
263 "ls", "cat", "head", "tail", "grep", "find", "echo", "pwd", "which", "type", "less", "more", "wc", "sort",
264 "uniq", "diff", "env", "printenv", "hostname", "uname", "date", "whoami", "id", "file", "stat", "tree",
265 "df", "du", "uptime",
266 ];
267
268 if safe_commands.contains(&cmd.as_str()) {
269 return Decision::Allow;
270 }
271
272 if command_might_be_dangerous(command) {
274 if command.iter().any(|arg| arg == "--dry-run" || arg == "-n") {
276 return Decision::Prompt;
277 }
278 return Decision::Forbidden;
279 }
280
281 Decision::Prompt
283 }
284
285 fn format_approval_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
287 match rule_match {
288 RuleMatch::PrefixRuleMatch { rule, .. } => {
289 format!(
290 "Command '{}' matched rule '{}' requiring confirmation",
291 command.join(" "),
292 rule.pattern.join(" ")
293 )
294 }
295 RuleMatch::HeuristicsRuleMatch { .. } => {
296 format!("Command '{}' requires confirmation (no explicit policy rule)", command.join(" "))
297 }
298 }
299 }
300
301 fn format_forbidden_reason(&self, command: &[String], rule_match: &RuleMatch) -> String {
303 match rule_match {
304 RuleMatch::PrefixRuleMatch { rule, .. } => {
305 format!("Command '{}' is forbidden by rule '{}'", command.join(" "), rule.pattern.join(" "))
306 }
307 RuleMatch::HeuristicsRuleMatch { .. } => {
308 format!("Command '{}' is forbidden by safety heuristics", command.join(" "))
309 }
310 }
311 }
312}
313
314pub type SharedExecPolicyManager = Arc<ExecPolicyManager>;
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use tempfile::tempdir;
321
322 #[tokio::test]
323 async fn test_policy_manager_basic() {
324 let dir = tempdir().unwrap();
325 let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
326
327 manager
329 .add_prefix_rule(&["cargo".to_string(), "build".to_string()], Decision::Allow)
330 .await
331 .unwrap();
332
333 let result = manager.check_approval(&["cargo".to_string(), "build".to_string()]).await;
335 assert!(result.can_proceed());
336
337 let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
339 assert!(result.requires_approval());
340 }
341
342 #[tokio::test]
343 async fn test_prompt_conflict_with_never_policy_forbids() {
344 let dir = tempdir().unwrap();
345 let manager = ExecPolicyManager::new(
346 dir.path().to_path_buf(),
347 ExecPolicyConfig {
348 default_approval: AskForApproval::Never,
349 ..ExecPolicyConfig::default()
350 },
351 );
352
353 let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
354 assert_eq!(result, ExecApprovalRequirement::forbidden(PROMPT_CONFLICT_REASON));
355 }
356
357 #[tokio::test]
358 async fn test_reject_rules_policy_forbids_rule_prompt() {
359 let dir = tempdir().unwrap();
360 let manager = ExecPolicyManager::new(
361 dir.path().to_path_buf(),
362 ExecPolicyConfig {
363 default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
364 sandbox_approval: false,
365 rules: true,
366 request_permissions: false,
367 mcp_elicitations: false,
368 }),
369 ..ExecPolicyConfig::default()
370 },
371 );
372 manager
373 .add_prefix_rule(&["git".to_string()], Decision::Prompt)
374 .await
375 .expect("add prompt rule");
376
377 let result = manager.check_approval(&["git".to_string()]).await;
378 assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_RULES_APPROVAL_REASON));
379 }
380
381 #[tokio::test]
382 async fn test_reject_sandbox_policy_forbids_non_rule_prompt() {
383 let dir = tempdir().unwrap();
384 let manager = ExecPolicyManager::new(
385 dir.path().to_path_buf(),
386 ExecPolicyConfig {
387 default_approval: AskForApproval::Reject(crate::exec_policy::RejectConfig {
388 sandbox_approval: true,
389 rules: false,
390 request_permissions: false,
391 mcp_elicitations: false,
392 }),
393 ..ExecPolicyConfig::default()
394 },
395 );
396
397 let result = manager.check_approval(&["unknown".to_string(), "command".to_string()]).await;
398 assert_eq!(result, ExecApprovalRequirement::forbidden(REJECT_SANDBOX_APPROVAL_REASON));
399 }
400
401 #[tokio::test]
402 async fn test_trusted_patterns() {
403 let dir = tempdir().unwrap();
404 let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
405
406 let amendment = ExecPolicyAmendment::from_prefix("cargo");
408 manager.add_trusted_pattern(amendment).await;
409
410 let result = manager.check_approval(&["cargo".to_string(), "test".to_string()]).await;
412 assert!(result.can_proceed());
413 }
414
415 #[tokio::test]
416 async fn test_session_approval() {
417 let dir = tempdir().unwrap();
418 let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
419
420 let cmd = vec!["git".to_string(), "status".to_string()];
421
422 let result = manager.check_approval(&cmd).await;
424 assert!(result.requires_approval());
425
426 manager.approve_command(&cmd).await;
428
429 let result = manager.check_approval(&cmd).await;
431 assert!(result.can_proceed());
432
433 manager.clear_session_approvals().await;
435
436 let result = manager.check_approval(&cmd).await;
438 assert!(result.requires_approval());
439 }
440
441 #[tokio::test]
442 async fn test_heuristics() {
443 let dir = tempdir().unwrap();
444 let manager = ExecPolicyManager::with_defaults(dir.path().to_path_buf());
445
446 let result = manager.check_approval(&["ls".to_string()]).await;
448 assert!(result.can_proceed());
449
450 let result = manager.check_approval(&["rm".to_string(), "-rf".to_string()]).await;
452 assert!(result.is_forbidden());
453 }
454}