1use crate::taint::TaintLevel;
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
12pub struct AllowlistRule {
13 pub tool: String,
15 #[serde(default)]
18 pub to: Vec<String>,
19 #[serde(default)]
21 pub channel: Vec<String>,
22 pub max_sensitivity: TaintLevel,
24}
25
26impl AllowlistRule {
27 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 self.to.is_empty() && self.channel.is_empty() {
39 return true;
40 }
41
42 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 target.is_none() && (!self.to.is_empty() || !self.channel.is_empty()) {
54 return false;
55 }
56
57 false
58 }
59}
60
61fn 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct McpToolOverride {
78 #[serde(default)]
80 pub sensitivity: Option<TaintLevel>,
81 #[serde(default)]
83 pub direction: Option<String>,
84 #[serde(default)]
86 pub clearance: Option<TaintLevel>,
87}
88
89#[derive(Debug, Clone, Default, Serialize, Deserialize)]
91pub struct PolicyConfig {
92 #[serde(default)]
94 pub allowlist: Vec<AllowlistRule>,
95 #[serde(default)]
97 pub mcp_overrides: std::collections::HashMap<String, McpToolOverride>,
98}
99
100impl PolicyConfig {
101 pub fn from_toml(content: &str) -> Result<Self, String> {
103 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 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 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 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 #[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 #[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 #[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 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 #[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 assert!(!rule.matches("post_message", None, TaintLevel::Public));
440 }
441
442 #[test]
443 fn test_from_toml_mcp_override_server_without_tools_table() {
444 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}