Skip to main content

vtcode_safety/exec_policy/
parser.rs

1//! Parser for policy rule files.
2//!
3//! Parses policy files in a simple line-based format similar to Codex's approach.
4//! Each line specifies a command prefix and a decision.
5
6use 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/// A serializable policy file format.
13#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct PolicyFile {
15    /// Format version for compatibility.
16    #[serde(default = "default_version")]
17    version: u32,
18
19    /// Policy rules.
20    rules: Vec<PolicyRule>,
21}
22
23fn default_version() -> u32 {
24    1
25}
26
27/// A single rule in the policy file.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct PolicyRule {
30    /// Command pattern (space-separated components).
31    pattern: String,
32
33    /// Decision for this pattern.
34    decision: Decision,
35
36    /// Optional comment explaining the rule.
37    #[serde(skip_serializing_if = "Option::is_none")]
38    comment: Option<String>,
39}
40
41/// Parser for policy files.
42#[derive(Debug, Default)]
43pub struct PolicyParser;
44
45impl PolicyParser {
46    /// Create a new parser.
47    pub(crate) fn new() -> Self {
48        Self
49    }
50
51    /// Parse a TOML policy file.
52    fn parse_toml(&self, content: &str) -> Result<PolicyFile> {
53        toml::from_str(content).context("Failed to parse policy TOML")
54    }
55
56    /// Parse a JSON policy file.
57    fn parse_json(&self, content: &str) -> Result<PolicyFile> {
58        parse_json_with_context(content, "policy JSON")
59    }
60
61    /// Parse a simple line-based format.
62    /// Format: "decision: pattern" or "pattern = decision"
63    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            // Skip empty lines and comments
70            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    /// Parse a single rule line.
85    fn parse_rule_line(&self, line: &str) -> Result<PrefixRule> {
86        // Try "decision: pattern" format
87        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        // Try "pattern = decision" format
94        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    /// Parse a decision string.
104    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    /// Parse a pattern string into components.
114    fn parse_pattern(&self, s: &str) -> Vec<String> {
115        s.split_whitespace().map(String::from).collect()
116    }
117
118    /// Load a policy from a file.
119    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    /// Load a policy from file content.
126    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}