Skip to main content

leviath_core/
policy.rs

1//! Policy rules for taint tracking allowlists.
2//!
3//! Users configure allowlist rules in `~/.config/leviath/policy.toml` to relax
4//! taint gating restrictions. Rules can be static (TOML pattern matching) or
5//! scripted (Rhai).
6
7use crate::taint::TaintLevel;
8use serde::{Deserialize, Serialize};
9
10/// A static allowlist rule from the policy file.
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct AllowlistRule {
13    /// Tool name this rule applies to.
14    pub tool: String,
15    /// Target patterns (e.g., email addresses, Slack channels).
16    /// If empty, matches any target.
17    #[serde(default)]
18    pub to: Vec<String>,
19    /// Channel patterns (for tools like Slack).
20    #[serde(default)]
21    pub channel: Vec<String>,
22    /// Maximum sensitivity level allowed by this rule.
23    pub max_sensitivity: TaintLevel,
24}
25
26impl AllowlistRule {
27    /// Check if this rule matches a given tool invocation.
28    pub fn matches(&self, tool_name: &str, target: Option<&str>, taint: TaintLevel) -> bool {
29        if self.tool != tool_name {
30            return false;
31        }
32
33        if taint > self.max_sensitivity {
34            return false;
35        }
36
37        // If no patterns specified, match any target
38        if self.to.is_empty() && self.channel.is_empty() {
39            return true;
40        }
41
42        // Check target against 'to' patterns
43        if let Some(target_str) = target {
44            if self.to.iter().any(|p| pattern_matches(p, target_str)) {
45                return true;
46            }
47            if self.channel.iter().any(|p| pattern_matches(p, target_str)) {
48                return true;
49            }
50        }
51
52        // If patterns are specified but no target provided, no match
53        if target.is_none() && (!self.to.is_empty() || !self.channel.is_empty()) {
54            return false;
55        }
56
57        false
58    }
59}
60
61/// Simple glob-like pattern matching: supports `*` as wildcard prefix/suffix.
62fn pattern_matches(pattern: &str, value: &str) -> bool {
63    if pattern == "*" {
64        return true;
65    }
66    if let Some(suffix) = pattern.strip_prefix('*') {
67        return value.ends_with(suffix);
68    }
69    if let Some(prefix) = pattern.strip_suffix('*') {
70        return value.starts_with(prefix);
71    }
72    pattern == value
73}
74
75/// MCP tool classification override.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct McpToolOverride {
78    /// Tool sensitivity level.
79    #[serde(default)]
80    pub sensitivity: Option<TaintLevel>,
81    /// Tool direction.
82    #[serde(default)]
83    pub direction: Option<String>,
84    /// Tool clearance level.
85    #[serde(default)]
86    pub clearance: Option<TaintLevel>,
87}
88
89/// Complete policy configuration loaded from policy.toml.
90#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct PolicyConfig {
92    /// Static allowlist rules.
93    #[serde(default)]
94    pub allowlist: Vec<AllowlistRule>,
95    /// MCP tool overrides keyed by "server_name.tool_name".
96    #[serde(default)]
97    pub mcp_overrides: std::collections::HashMap<String, McpToolOverride>,
98}
99
100impl PolicyConfig {
101    /// Parse a policy config from TOML string.
102    pub fn from_toml(content: &str) -> Result<Self, String> {
103        // Parse the raw TOML
104        let parsed: toml::Value =
105            toml::from_str(content).map_err(|e| format!("Failed to parse policy.toml: {}", e))?;
106
107        let mut config = PolicyConfig::default();
108
109        // Parse [[allowlist]] array
110        if let Some(allowlist_arr) = parsed.get("allowlist").and_then(|v| v.as_array()) {
111            for rule_val in allowlist_arr {
112                let tool = rule_val
113                    .get("tool")
114                    .and_then(|v| v.as_str())
115                    .unwrap_or("")
116                    .to_string();
117
118                let to: Vec<String> = rule_val
119                    .get("to")
120                    .and_then(|v| v.as_array())
121                    .map(|arr| {
122                        arr.iter()
123                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
124                            .collect()
125                    })
126                    .unwrap_or_default();
127
128                let channel: Vec<String> = rule_val
129                    .get("channel")
130                    .and_then(|v| v.as_array())
131                    .map(|arr| {
132                        arr.iter()
133                            .filter_map(|v| v.as_str().map(|s| s.to_string()))
134                            .collect()
135                    })
136                    .unwrap_or_default();
137
138                let max_sensitivity = rule_val
139                    .get("max_sensitivity")
140                    .and_then(|v| v.as_str())
141                    .and_then(TaintLevel::from_str_loose)
142                    .unwrap_or(TaintLevel::Public);
143
144                config.allowlist.push(AllowlistRule {
145                    tool,
146                    to,
147                    channel,
148                    max_sensitivity,
149                });
150            }
151        }
152
153        // Parse [mcp_overrides] section
154        if let Some(overrides_table) = parsed.get("mcp_overrides").and_then(|v| v.as_table()) {
155            for (server_name, server_val) in overrides_table {
156                if let Some(tools_table) = server_val.get("tools").and_then(|v| v.as_table()) {
157                    for (tool_name, tool_val) in tools_table {
158                        let key = format!("{}.{}", server_name, tool_name);
159                        let sensitivity = tool_val
160                            .get("sensitivity")
161                            .and_then(|v| v.as_str())
162                            .and_then(TaintLevel::from_str_loose);
163                        let direction = tool_val
164                            .get("direction")
165                            .and_then(|v| v.as_str())
166                            .map(|s| s.to_string());
167                        let clearance = tool_val
168                            .get("clearance")
169                            .and_then(|v| v.as_str())
170                            .and_then(TaintLevel::from_str_loose);
171
172                        config.mcp_overrides.insert(
173                            key,
174                            McpToolOverride {
175                                sensitivity,
176                                direction,
177                                clearance,
178                            },
179                        );
180                    }
181                }
182            }
183        }
184
185        Ok(config)
186    }
187
188    /// Check whether any allowlist rule matches the given invocation.
189    /// Returns the index of the matching rule, if any.
190    pub fn check_allowlist(
191        &self,
192        tool_name: &str,
193        target: Option<&str>,
194        taint: TaintLevel,
195    ) -> Option<usize> {
196        self.allowlist
197            .iter()
198            .position(|rule| rule.matches(tool_name, target, taint))
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    // ─── pattern_matches ────────────────────────────────────────────────────
207
208    #[test]
209    fn pattern_matches_exact() {
210        assert!(pattern_matches("hello", "hello"));
211        assert!(!pattern_matches("hello", "world"));
212    }
213
214    #[test]
215    fn pattern_matches_wildcard_all() {
216        assert!(pattern_matches("*", "anything"));
217        assert!(pattern_matches("*", ""));
218    }
219
220    #[test]
221    fn pattern_matches_wildcard_prefix() {
222        assert!(pattern_matches("*@example.com", "user@example.com"));
223        assert!(!pattern_matches("*@example.com", "user@other.com"));
224    }
225
226    #[test]
227    fn pattern_matches_wildcard_suffix() {
228        assert!(pattern_matches("megan@*", "megan@anywhere.com"));
229        assert!(!pattern_matches("megan@*", "bob@anywhere.com"));
230    }
231
232    // ─── AllowlistRule::matches ──────────────────────────────────────────────
233
234    #[test]
235    fn rule_matches_tool_and_sensitivity() {
236        let rule = AllowlistRule {
237            tool: "send_email".into(),
238            to: vec![],
239            channel: vec![],
240            max_sensitivity: TaintLevel::Private,
241        };
242        assert!(rule.matches("send_email", None, TaintLevel::Private));
243        assert!(rule.matches("send_email", None, TaintLevel::Public));
244        assert!(!rule.matches("other_tool", None, TaintLevel::Private));
245    }
246
247    #[test]
248    fn rule_blocks_above_max_sensitivity() {
249        let rule = AllowlistRule {
250            tool: "send_email".into(),
251            to: vec![],
252            channel: vec![],
253            max_sensitivity: TaintLevel::Internal,
254        };
255        assert!(!rule.matches("send_email", None, TaintLevel::Private));
256    }
257
258    #[test]
259    fn rule_matches_target_pattern() {
260        let rule = AllowlistRule {
261            tool: "send_email".into(),
262            to: vec!["megan@*".into(), "+17576306267".into()],
263            channel: vec![],
264            max_sensitivity: TaintLevel::Private,
265        };
266        assert!(rule.matches("send_email", Some("megan@work.com"), TaintLevel::Internal));
267        assert!(rule.matches("send_email", Some("+17576306267"), TaintLevel::Internal));
268        assert!(!rule.matches("send_email", Some("bob@work.com"), TaintLevel::Internal));
269    }
270
271    #[test]
272    fn rule_matches_channel_pattern() {
273        let rule = AllowlistRule {
274            tool: "post_to_slack".into(),
275            to: vec![],
276            channel: vec!["#team-standup".into()],
277            max_sensitivity: TaintLevel::Internal,
278        };
279        assert!(rule.matches("post_to_slack", Some("#team-standup"), TaintLevel::Internal));
280        assert!(!rule.matches("post_to_slack", Some("#general"), TaintLevel::Internal));
281    }
282
283    #[test]
284    fn rule_no_match_when_patterns_but_no_target() {
285        let rule = AllowlistRule {
286            tool: "send_email".into(),
287            to: vec!["megan@*".into()],
288            channel: vec![],
289            max_sensitivity: TaintLevel::Private,
290        };
291        assert!(!rule.matches("send_email", None, TaintLevel::Internal));
292    }
293
294    // ─── PolicyConfig::from_toml ────────────────────────────────────────────
295
296    #[test]
297    fn parse_policy_with_allowlist() {
298        let toml = r##"
299[[allowlist]]
300tool = "send_email"
301to = ["megan@*", "+17576306267"]
302max_sensitivity = "private"
303
304[[allowlist]]
305tool = "post_to_slack"
306channel = ["#team-standup"]
307max_sensitivity = "internal"
308"##;
309        let config = PolicyConfig::from_toml(toml).unwrap();
310        assert_eq!(config.allowlist.len(), 2);
311        assert_eq!(config.allowlist[0].tool, "send_email");
312        assert_eq!(config.allowlist[0].to.len(), 2);
313        assert_eq!(config.allowlist[0].max_sensitivity, TaintLevel::Private);
314        assert_eq!(config.allowlist[1].tool, "post_to_slack");
315        assert_eq!(config.allowlist[1].channel, vec!["#team-standup"]);
316    }
317
318    #[test]
319    fn parse_policy_with_mcp_overrides() {
320        let toml = r#"
321[mcp_overrides."my-server".tools]
322read_customer_data = { sensitivity = "private" }
323search_public_docs = { sensitivity = "public" }
324"#;
325        let config = PolicyConfig::from_toml(toml).unwrap();
326        assert_eq!(config.mcp_overrides.len(), 2);
327        let cust = config
328            .mcp_overrides
329            .get("my-server.read_customer_data")
330            .unwrap();
331        assert_eq!(cust.sensitivity, Some(TaintLevel::Private));
332        let docs = config
333            .mcp_overrides
334            .get("my-server.search_public_docs")
335            .unwrap();
336        assert_eq!(docs.sensitivity, Some(TaintLevel::Public));
337    }
338
339    #[test]
340    fn parse_policy_mcp_override_with_direction_and_clearance() {
341        // Exercises the direction/clearance branches of `[mcp_overrides]`
342        // parsing, which a sensitivity-only override never reaches.
343        let toml = r#"
344[mcp_overrides."srv".tools]
345send_email = { sensitivity = "private", direction = "egress", clearance = "public" }
346"#;
347        let config = PolicyConfig::from_toml(toml).unwrap();
348        let ov = config.mcp_overrides.get("srv.send_email").unwrap();
349        assert_eq!(ov.sensitivity, Some(TaintLevel::Private));
350        assert_eq!(ov.direction.as_deref(), Some("egress"));
351        assert_eq!(ov.clearance, Some(TaintLevel::Public));
352    }
353
354    #[test]
355    fn parse_policy_empty() {
356        let config = PolicyConfig::from_toml("").unwrap();
357        assert!(config.allowlist.is_empty());
358        assert!(config.mcp_overrides.is_empty());
359    }
360
361    #[test]
362    fn parse_policy_invalid_toml() {
363        let result = PolicyConfig::from_toml("{{invalid}}");
364        assert!(result.is_err());
365    }
366
367    #[test]
368    fn check_allowlist_returns_matching_index() {
369        let config = PolicyConfig {
370            allowlist: vec![
371                AllowlistRule {
372                    tool: "send_email".into(),
373                    to: vec!["megan@*".into()],
374                    channel: vec![],
375                    max_sensitivity: TaintLevel::Private,
376                },
377                AllowlistRule {
378                    tool: "post_to_slack".into(),
379                    to: vec![],
380                    channel: vec![],
381                    max_sensitivity: TaintLevel::Internal,
382                },
383            ],
384            mcp_overrides: Default::default(),
385        };
386
387        assert_eq!(
388            config.check_allowlist("send_email", Some("megan@work.com"), TaintLevel::Internal),
389            Some(0)
390        );
391        assert_eq!(
392            config.check_allowlist("post_to_slack", None, TaintLevel::Internal),
393            Some(1)
394        );
395        assert_eq!(
396            config.check_allowlist("unknown", None, TaintLevel::Public),
397            None
398        );
399    }
400
401    // ─── Serde roundtrips ───────────────────────────────────────────────────
402
403    #[test]
404    fn allowlist_rule_serde_roundtrip() {
405        let rule = AllowlistRule {
406            tool: "send_email".into(),
407            to: vec!["test@*".into()],
408            channel: vec![],
409            max_sensitivity: TaintLevel::Private,
410        };
411        let json = serde_json::to_string(&rule).unwrap();
412        let back: AllowlistRule = serde_json::from_str(&json).unwrap();
413        assert_eq!(rule, back);
414    }
415
416    #[test]
417    fn mcp_override_serde_roundtrip() {
418        let o = McpToolOverride {
419            sensitivity: Some(TaintLevel::Private),
420            direction: Some("outbound".into()),
421            clearance: Some(TaintLevel::Internal),
422        };
423        let json = serde_json::to_string(&o).unwrap();
424        let back: McpToolOverride = serde_json::from_str(&json).unwrap();
425        assert_eq!(o, back);
426    }
427
428    #[test]
429    fn test_matches_false_when_only_channel_pattern_set_but_no_target() {
430        let rule = AllowlistRule {
431            tool: "post_message".to_string(),
432            to: vec![],
433            channel: vec!["#general".to_string()],
434            max_sensitivity: TaintLevel::Private,
435        };
436        // `to` is empty (first operand false), which forces evaluation of the
437        // `channel` operand in the "patterns set but no target" guard; with no
438        // target the rule must not match.
439        assert!(!rule.matches("post_message", None, TaintLevel::Public));
440    }
441
442    #[test]
443    fn test_from_toml_mcp_override_server_without_tools_table() {
444        // A server entry that has no `tools` sub-table exercises the
445        // `if let Some(tools_table)` None branch - nothing is inserted.
446        let toml = r#"
447[mcp_overrides.emptyserver]
448note = "no tools declared here"
449"#;
450        let config = PolicyConfig::from_toml(toml).unwrap();
451        assert!(config.mcp_overrides.is_empty());
452    }
453}