vtcode_safety/exec_policy/
parser.rs1use super::policy::{Decision, Policy, PrefixRule};
7use anyhow::{Context, Result};
8use serde::{Deserialize, Serialize};
9use std::path::Path;
10use vtcode_commons::fs::{parse_json_with_context, read_file_with_context};
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct PolicyFile {
15 #[serde(default = "default_version")]
17 version: u32,
18
19 rules: Vec<PolicyRule>,
21}
22
23fn default_version() -> u32 {
24 1
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PolicyRule {
30 pattern: String,
32
33 decision: Decision,
35
36 #[serde(skip_serializing_if = "Option::is_none")]
38 comment: Option<String>,
39}
40
41#[derive(Debug, Default)]
43pub struct PolicyParser;
44
45impl PolicyParser {
46 pub(crate) fn new() -> Self {
48 Self
49 }
50
51 fn parse_toml(&self, content: &str) -> Result<PolicyFile> {
53 toml::from_str(content).context("Failed to parse policy TOML")
54 }
55
56 fn parse_json(&self, content: &str) -> Result<PolicyFile> {
58 parse_json_with_context(content, "policy JSON")
59 }
60
61 fn parse_simple(&self, content: &str) -> Result<Vec<PrefixRule>> {
64 let mut rules = Vec::new();
65
66 for (line_num, line) in content.lines().enumerate() {
67 let line = line.trim();
68
69 if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
71 continue;
72 }
73
74 let rule = self
75 .parse_rule_line(line)
76 .with_context(|| format!("Failed to parse line {}: {}", line_num + 1, line))?;
77
78 rules.push(rule);
79 }
80
81 Ok(rules)
82 }
83
84 fn parse_rule_line(&self, line: &str) -> Result<PrefixRule> {
86 if let Some((decision_str, pattern)) = line.split_once(':') {
88 let decision = self.parse_decision(decision_str.trim())?;
89 let pattern = self.parse_pattern(pattern.trim());
90 return Ok(PrefixRule::new(pattern, decision));
91 }
92
93 if let Some((pattern, decision_str)) = line.split_once('=') {
95 let decision = self.parse_decision(decision_str.trim())?;
96 let pattern = self.parse_pattern(pattern.trim());
97 return Ok(PrefixRule::new(pattern, decision));
98 }
99
100 anyhow::bail!("Invalid rule format. Expected 'decision: pattern' or 'pattern = decision'")
101 }
102
103 fn parse_decision(&self, s: &str) -> Result<Decision> {
105 match s.to_lowercase().as_str() {
106 "allow" | "yes" | "true" | "1" => Ok(Decision::Allow),
107 "prompt" | "ask" | "confirm" => Ok(Decision::Prompt),
108 "forbidden" | "forbid" | "deny" | "no" | "false" | "0" => Ok(Decision::Forbidden),
109 _ => anyhow::bail!("Invalid decision: {s}"),
110 }
111 }
112
113 fn parse_pattern(&self, s: &str) -> Vec<String> {
115 s.split_whitespace().map(String::from).collect()
116 }
117
118 pub async fn load_file(&self, path: &Path) -> Result<Policy> {
120 let content = read_file_with_context(path, "policy file").await?;
121
122 self.load_from_content(&content, path)
123 }
124
125 pub fn load_from_content(&self, content: &str, path: &Path) -> Result<Policy> {
127 let extension = path.extension().and_then(|e| e.to_str()).unwrap_or("");
128
129 let rules = match extension {
130 "toml" => {
131 let file = self.parse_toml(content)?;
132 file.rules
133 .into_iter()
134 .map(|r| PrefixRule::new(r.pattern.split_whitespace().map(String::from).collect(), r.decision))
135 .collect()
136 }
137 "json" => {
138 let file = self.parse_json(content)?;
139 file.rules
140 .into_iter()
141 .map(|r| PrefixRule::new(r.pattern.split_whitespace().map(String::from).collect(), r.decision))
142 .collect()
143 }
144 _ => self.parse_simple(content)?,
145 };
146
147 let mut policy = Policy::empty();
148 for (index, rule) in rules.into_iter().enumerate() {
149 policy
150 .add_prefix_rule(&rule.pattern, rule.decision)
151 .with_context(|| format!("Invalid policy rule {} in {}", index + 1, path.display()))?;
152 }
153
154 Ok(policy)
155 }
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn blank_patterns_fail_with_file_and_rule_context() {
164 let parser = PolicyParser::new();
165 let cases = [
166 ("blank.rules", "allow: git\nallow: "),
167 ("blank.rules", "git = allow\n = forbidden"),
168 (
169 "blank.toml",
170 "[[rules]]\npattern = 'git'\ndecision = 'allow'\n[[rules]]\npattern = ' '\ndecision = 'allow'",
171 ),
172 (
173 "blank.json",
174 r#"{"rules":[{"pattern":"git","decision":"allow"},{"pattern":" ","decision":"allow"}]}"#,
175 ),
176 ];
177 for (filename, content) in cases {
178 let error = parser.load_from_content(content, Path::new(filename)).unwrap_err();
179 let message = format!("{error:#}");
180 assert!(message.contains(filename), "{message}");
181 assert!(message.contains("rule 2"), "{message}");
182 assert!(message.contains("empty"), "{message}");
183 }
184 }
185
186 #[test]
187 fn empty_rule_files_remain_valid() {
188 let parser = PolicyParser::new();
189 for content in ["", " \n# comment\n// comment\n"] {
190 assert!(
191 parser
192 .load_from_content(content, Path::new("empty.rules"))
193 .unwrap()
194 .rules()
195 .is_empty()
196 );
197 }
198 for (filename, content) in [
199 ("empty.toml", "# comment\nrules = []"),
200 ("empty.json", r#"{"rules":[]}"#),
201 ] {
202 assert!(
203 parser
204 .load_from_content(content, Path::new(filename))
205 .unwrap()
206 .rules()
207 .is_empty()
208 );
209 }
210 }
211
212 #[test]
213 fn test_parse_simple_format() {
214 let parser = PolicyParser::new();
215 let content = r#"
216# Allow cargo commands
217allow: cargo build
218allow: cargo test
219
220# Forbid dangerous commands
221forbidden: rm -rf
222prompt: git push
223"#;
224
225 let rules = parser.parse_simple(content).unwrap();
226 assert_eq!(rules.len(), 4);
227
228 assert_eq!(rules[0].pattern, vec!["cargo", "build"]);
229 assert_eq!(rules[0].decision, Decision::Allow);
230
231 assert_eq!(rules[3].pattern, vec!["git", "push"]);
232 assert_eq!(rules[3].decision, Decision::Prompt);
233 }
234
235 #[test]
236 fn test_parse_equals_format() {
237 let parser = PolicyParser::new();
238 let content = r#"
239cargo build = allow
240rm -rf = deny
241"#;
242
243 let rules = parser.parse_simple(content).unwrap();
244 assert_eq!(rules.len(), 2);
245
246 assert_eq!(rules[0].decision, Decision::Allow);
247 assert_eq!(rules[1].decision, Decision::Forbidden);
248 }
249
250 #[test]
251 fn test_parse_toml() {
252 let parser = PolicyParser::new();
253 let content = r#"
254version = 1
255
256[[rules]]
257pattern = "cargo build"
258decision = "allow"
259
260[[rules]]
261pattern = "rm -rf"
262decision = "forbidden"
263comment = "Never allow recursive delete"
264"#;
265
266 let file = parser.parse_toml(content).unwrap();
267 assert_eq!(file.rules.len(), 2);
268 assert_eq!(file.rules[0].decision, Decision::Allow);
269 assert_eq!(file.rules[1].comment, Some("Never allow recursive delete".to_string()));
270 }
271
272 #[test]
273 fn test_parse_json() {
274 let parser = PolicyParser::new();
275 let content = r#"{
276 "version": 1,
277 "rules": [
278 {"pattern": "cargo test", "decision": "allow"},
279 {"pattern": "git push", "decision": "prompt"}
280 ]
281 }"#;
282
283 let file = parser.parse_json(content).unwrap();
284 assert_eq!(file.rules.len(), 2);
285 }
286}