1use super::capability::Capability;
35use super::document::PolicyDocument;
36
37pub const L7_ONLY_DIMENSIONS: &[&str] = &[
43 "budget",
44 "schedule",
45 "data.credential_scan",
46 "tools.limit_per_hour",
47 "tools.non_path_predicates",
48 "capability.agent_spawn",
49 "capability.model",
50 "capability.mcp_tool",
51 "capability.network_inbound",
52 "capability.terminal_exec",
53];
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
62pub enum PathVerdict {
63 Allow,
65 Deny,
67}
68
69#[derive(Debug, Clone, PartialEq, Eq)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct PathRule {
76 pub pattern: String,
78 pub verdict: PathVerdict,
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Default)]
85#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
86pub struct EbpfRuleSet {
87 pub path_rules: Vec<PathRule>,
89 pub egress_allowlist: Vec<String>,
91 pub syscall_allowlist: Vec<u32>,
97}
98
99impl EbpfRuleSet {
100 pub fn deny_paths(&self) -> impl Iterator<Item = &str> {
102 self.path_rules
103 .iter()
104 .filter(|r| r.verdict == PathVerdict::Deny)
105 .map(|r| r.pattern.as_str())
106 }
107}
108
109const SENSITIVE_MUTATION_DENY_DEFAULTS: &[&str] = &["/etc", "/root/.ssh", "/var/run/secrets"];
115
116pub fn lower_to_ebpf(doc: &PolicyDocument) -> EbpfRuleSet {
121 let mut path_rules: Vec<PathRule> = Vec::new();
122
123 let denies_file_mutation = doc
127 .capabilities
128 .as_ref()
129 .map(|c| c.deny.contains(&Capability::FileWrite) || c.deny.contains(&Capability::FileDelete))
130 .unwrap_or(false);
131 if denies_file_mutation {
132 for prefix in SENSITIVE_MUTATION_DENY_DEFAULTS {
133 push_unique(
134 &mut path_rules,
135 PathRule {
136 pattern: (*prefix).to_string(),
137 verdict: PathVerdict::Deny,
138 },
139 );
140 }
141 }
142
143 for tool in &doc.tools {
145 if let Some(expr) = &tool.requires_approval_if {
146 for prefix in extract_path_prefixes(expr) {
147 push_unique(
148 &mut path_rules,
149 PathRule {
150 pattern: prefix,
151 verdict: PathVerdict::Deny,
152 },
153 );
154 }
155 }
156 }
157
158 EbpfRuleSet {
159 path_rules,
160 egress_allowlist: doc.egress_allowlist().to_vec(),
161 syscall_allowlist: lower_syscall_allowlist(doc),
162 }
163}
164
165fn lower_syscall_allowlist(doc: &PolicyDocument) -> Vec<u32> {
174 doc.syscall_allowlist
175 .as_ref()
176 .map(|a| a.iter().map(|s| s.number()).collect())
177 .unwrap_or_default()
178}
179
180fn push_unique(rules: &mut Vec<PathRule>, rule: PathRule) {
183 if !rules.contains(&rule) {
184 rules.push(rule);
185 }
186}
187
188fn extract_path_prefixes(expr: &str) -> Vec<String> {
193 const NEEDLE: &str = "path starts_with";
194 let mut out = Vec::new();
195 let mut rest = expr;
196 while let Some(idx) = rest.find(NEEDLE) {
197 let after = &rest[idx + NEEDLE.len()..];
198 if let Some(prefix) = first_quoted(after) {
199 out.push(prefix);
200 }
201 rest = after;
202 }
203 out
204}
205
206fn first_quoted(s: &str) -> Option<String> {
208 let start = s.find('"')? + 1;
209 let end = s[start..].find('"')? + start;
210 Some(s[start..end].to_string())
211}
212
213#[cfg(test)]
214mod tests {
215 use super::super::capability::CapabilitySet;
216 use super::super::document::{NetworkPolicy, ToolRule};
217 use super::*;
218
219 fn doc_with(caps: Option<CapabilitySet>, tools: Vec<ToolRule>, allowlist: Vec<String>) -> PolicyDocument {
220 PolicyDocument {
221 name: None,
222 network: (!allowlist.is_empty()).then_some(NetworkPolicy { allowlist }),
223 capabilities: caps,
224 tools,
225 syscall_allowlist: None,
226 }
227 }
228
229 #[test]
230 fn file_write_deny_seeds_sensitive_path_denies() {
231 let mut caps = CapabilitySet::default();
232 caps.deny.insert(Capability::FileWrite);
233 let rules = lower_to_ebpf(&doc_with(Some(caps), vec![], vec![]));
234 let deny: Vec<&str> = rules.deny_paths().collect();
235 assert!(deny.contains(&"/etc"));
236 assert!(deny.contains(&"/root/.ssh"));
237 }
238
239 #[test]
240 fn file_delete_deny_seeds_sensitive_path_denies() {
241 let mut caps = CapabilitySet::default();
244 caps.deny.insert(Capability::FileDelete);
245 let rules = lower_to_ebpf(&doc_with(Some(caps), vec![], vec![]));
246 let deny: Vec<&str> = rules.deny_paths().collect();
247 assert!(deny.contains(&"/etc"));
248 assert!(deny.contains(&"/root/.ssh"));
249 }
250
251 #[test]
252 fn no_file_write_deny_means_no_default_path_rules() {
253 let rules = lower_to_ebpf(&doc_with(None, vec![], vec![]));
254 assert!(rules.path_rules.is_empty());
255 }
256
257 #[test]
258 fn tool_path_predicate_lowers_to_deny_rule() {
259 let tools = vec![ToolRule {
260 name: "write_file".to_string(),
261 allow: true,
262 requires_approval_if: Some("path starts_with \"/etc\"".to_string()),
263 }];
264 let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
265 assert_eq!(
266 rules.path_rules,
267 vec![PathRule {
268 pattern: "/etc".to_string(),
269 verdict: PathVerdict::Deny,
270 }]
271 );
272 }
273
274 #[test]
275 fn compound_predicate_extracts_all_path_prefixes() {
276 let tools = vec![ToolRule {
277 name: "x".to_string(),
278 allow: true,
279 requires_approval_if: Some("path starts_with \"/etc\" AND path starts_with \"/root\"".to_string()),
280 }];
281 let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
282 let deny: Vec<&str> = rules.deny_paths().collect();
283 assert!(deny.contains(&"/etc"));
284 assert!(deny.contains(&"/root"));
285 }
286
287 #[test]
288 fn non_path_predicate_is_l7_only_and_lowers_nothing() {
289 let tools = vec![ToolRule {
290 name: "http_get".to_string(),
291 allow: true,
292 requires_approval_if: Some("url contains \"internal\"".to_string()),
293 }];
294 let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
295 assert!(rules.path_rules.is_empty());
296 }
297
298 #[test]
299 fn egress_allowlist_is_copied_verbatim() {
300 let rules = lower_to_ebpf(&doc_with(None, vec![], vec!["api.openai.com".to_string()]));
301 assert_eq!(rules.egress_allowlist, vec!["api.openai.com".to_string()]);
302 }
303
304 #[test]
305 fn duplicate_path_rules_are_deduplicated() {
306 let tools = vec![
307 ToolRule {
308 name: "a".to_string(),
309 allow: true,
310 requires_approval_if: Some("path starts_with \"/etc\"".to_string()),
311 },
312 ToolRule {
313 name: "b".to_string(),
314 allow: true,
315 requires_approval_if: Some("path starts_with \"/etc\"".to_string()),
316 },
317 ];
318 let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
319 assert_eq!(rules.path_rules.len(), 1);
320 }
321
322 #[test]
323 fn syscall_allowlist_lowers_to_exact_numbers() {
324 use super::super::syscall::SyscallAllowlist;
325 let mut doc = doc_with(None, vec![], vec![]);
326 doc.syscall_allowlist = Some(SyscallAllowlist::from_names(["read", "write", "close", "exit"]).unwrap());
327 let rules = lower_to_ebpf(&doc);
328 assert_eq!(rules.syscall_allowlist, vec![0, 1, 3, 60]);
331 }
332
333 #[test]
334 fn absent_syscall_allowlist_lowers_to_empty() {
335 let rules = lower_to_ebpf(&doc_with(None, vec![], vec![]));
336 assert!(rules.syscall_allowlist.is_empty());
337 }
338
339 #[test]
340 fn syscall_lowering_is_deterministic() {
341 use super::super::syscall::SyscallAllowlist;
342 let mut doc = doc_with(None, vec![], vec![]);
343 doc.syscall_allowlist = Some(SyscallAllowlist::from_names(["write", "read", "openat"]).unwrap());
344 assert_eq!(
345 lower_to_ebpf(&doc).syscall_allowlist,
346 lower_to_ebpf(&doc).syscall_allowlist
347 );
348 }
349
350 #[test]
351 fn lowering_is_deterministic() {
352 let mut caps = CapabilitySet::default();
353 caps.deny.insert(Capability::FileWrite);
354 let tools = vec![ToolRule {
355 name: "write_file".to_string(),
356 allow: true,
357 requires_approval_if: Some("path starts_with \"/var\"".to_string()),
358 }];
359 let doc = doc_with(Some(caps), tools, vec!["h".to_string()]);
360 assert_eq!(lower_to_ebpf(&doc), lower_to_ebpf(&doc));
361 }
362}