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