use super::capability::Capability;
use super::document::PolicyDocument;
pub const L7_ONLY_DIMENSIONS: &[&str] = &[
"budget",
"schedule",
"data.credential_scan",
"tools.limit_per_hour",
"tools.non_path_predicates",
"capability.agent_spawn",
"capability.model",
"capability.mcp_tool",
"capability.network_inbound",
"capability.terminal_exec",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum PathVerdict {
Allow,
Deny,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PathRule {
pub pattern: String,
pub verdict: PathVerdict,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EbpfRuleSet {
pub path_rules: Vec<PathRule>,
pub egress_allowlist: Vec<String>,
pub syscall_allowlist: Vec<u32>,
}
impl EbpfRuleSet {
pub fn deny_paths(&self) -> impl Iterator<Item = &str> {
self.path_rules
.iter()
.filter(|r| r.verdict == PathVerdict::Deny)
.map(|r| r.pattern.as_str())
}
}
const SENSITIVE_WRITE_DENY_DEFAULTS: &[&str] = &["/etc", "/root/.ssh", "/var/run/secrets"];
pub fn lower_to_ebpf(doc: &PolicyDocument) -> EbpfRuleSet {
let mut path_rules: Vec<PathRule> = Vec::new();
let denies_file_write = doc
.capabilities
.as_ref()
.map(|c| c.deny.contains(&Capability::FileWrite))
.unwrap_or(false);
if denies_file_write {
for prefix in SENSITIVE_WRITE_DENY_DEFAULTS {
push_unique(
&mut path_rules,
PathRule {
pattern: (*prefix).to_string(),
verdict: PathVerdict::Deny,
},
);
}
}
for tool in &doc.tools {
if let Some(expr) = &tool.requires_approval_if {
for prefix in extract_path_prefixes(expr) {
push_unique(
&mut path_rules,
PathRule {
pattern: prefix,
verdict: PathVerdict::Deny,
},
);
}
}
}
EbpfRuleSet {
path_rules,
egress_allowlist: doc.egress_allowlist().to_vec(),
syscall_allowlist: lower_syscall_allowlist(doc),
}
}
fn lower_syscall_allowlist(doc: &PolicyDocument) -> Vec<u32> {
doc.syscall_allowlist
.as_ref()
.map(|a| a.iter().map(|s| s.number()).collect())
.unwrap_or_default()
}
fn push_unique(rules: &mut Vec<PathRule>, rule: PathRule) {
if !rules.contains(&rule) {
rules.push(rule);
}
}
fn extract_path_prefixes(expr: &str) -> Vec<String> {
const NEEDLE: &str = "path starts_with";
let mut out = Vec::new();
let mut rest = expr;
while let Some(idx) = rest.find(NEEDLE) {
let after = &rest[idx + NEEDLE.len()..];
if let Some(prefix) = first_quoted(after) {
out.push(prefix);
}
rest = after;
}
out
}
fn first_quoted(s: &str) -> Option<String> {
let start = s.find('"')? + 1;
let end = s[start..].find('"')? + start;
Some(s[start..end].to_string())
}
#[cfg(test)]
mod tests {
use super::super::capability::CapabilitySet;
use super::super::document::{NetworkPolicy, ToolRule};
use super::*;
fn doc_with(caps: Option<CapabilitySet>, tools: Vec<ToolRule>, allowlist: Vec<String>) -> PolicyDocument {
PolicyDocument {
name: None,
network: (!allowlist.is_empty()).then_some(NetworkPolicy { allowlist }),
capabilities: caps,
tools,
syscall_allowlist: None,
}
}
#[test]
fn file_write_deny_seeds_sensitive_path_denies() {
let mut caps = CapabilitySet::default();
caps.deny.insert(Capability::FileWrite);
let rules = lower_to_ebpf(&doc_with(Some(caps), vec![], vec![]));
let deny: Vec<&str> = rules.deny_paths().collect();
assert!(deny.contains(&"/etc"));
assert!(deny.contains(&"/root/.ssh"));
}
#[test]
fn no_file_write_deny_means_no_default_path_rules() {
let rules = lower_to_ebpf(&doc_with(None, vec![], vec![]));
assert!(rules.path_rules.is_empty());
}
#[test]
fn tool_path_predicate_lowers_to_deny_rule() {
let tools = vec![ToolRule {
name: "write_file".to_string(),
allow: true,
requires_approval_if: Some("path starts_with \"/etc\"".to_string()),
}];
let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
assert_eq!(
rules.path_rules,
vec![PathRule {
pattern: "/etc".to_string(),
verdict: PathVerdict::Deny,
}]
);
}
#[test]
fn compound_predicate_extracts_all_path_prefixes() {
let tools = vec![ToolRule {
name: "x".to_string(),
allow: true,
requires_approval_if: Some("path starts_with \"/etc\" AND path starts_with \"/root\"".to_string()),
}];
let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
let deny: Vec<&str> = rules.deny_paths().collect();
assert!(deny.contains(&"/etc"));
assert!(deny.contains(&"/root"));
}
#[test]
fn non_path_predicate_is_l7_only_and_lowers_nothing() {
let tools = vec![ToolRule {
name: "http_get".to_string(),
allow: true,
requires_approval_if: Some("url contains \"internal\"".to_string()),
}];
let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
assert!(rules.path_rules.is_empty());
}
#[test]
fn egress_allowlist_is_copied_verbatim() {
let rules = lower_to_ebpf(&doc_with(None, vec![], vec!["api.openai.com".to_string()]));
assert_eq!(rules.egress_allowlist, vec!["api.openai.com".to_string()]);
}
#[test]
fn duplicate_path_rules_are_deduplicated() {
let tools = vec![
ToolRule {
name: "a".to_string(),
allow: true,
requires_approval_if: Some("path starts_with \"/etc\"".to_string()),
},
ToolRule {
name: "b".to_string(),
allow: true,
requires_approval_if: Some("path starts_with \"/etc\"".to_string()),
},
];
let rules = lower_to_ebpf(&doc_with(None, tools, vec![]));
assert_eq!(rules.path_rules.len(), 1);
}
#[test]
fn syscall_allowlist_lowers_to_exact_numbers() {
use super::super::syscall::SyscallAllowlist;
let mut doc = doc_with(None, vec![], vec![]);
doc.syscall_allowlist = Some(SyscallAllowlist::from_names(["read", "write", "close", "exit"]).unwrap());
let rules = lower_to_ebpf(&doc);
assert_eq!(rules.syscall_allowlist, vec![0, 1, 3, 60]);
}
#[test]
fn absent_syscall_allowlist_lowers_to_empty() {
let rules = lower_to_ebpf(&doc_with(None, vec![], vec![]));
assert!(rules.syscall_allowlist.is_empty());
}
#[test]
fn syscall_lowering_is_deterministic() {
use super::super::syscall::SyscallAllowlist;
let mut doc = doc_with(None, vec![], vec![]);
doc.syscall_allowlist = Some(SyscallAllowlist::from_names(["write", "read", "openat"]).unwrap());
assert_eq!(
lower_to_ebpf(&doc).syscall_allowlist,
lower_to_ebpf(&doc).syscall_allowlist
);
}
#[test]
fn lowering_is_deterministic() {
let mut caps = CapabilitySet::default();
caps.deny.insert(Capability::FileWrite);
let tools = vec![ToolRule {
name: "write_file".to_string(),
allow: true,
requires_approval_if: Some("path starts_with \"/var\"".to_string()),
}];
let doc = doc_with(Some(caps), tools, vec!["h".to_string()]);
assert_eq!(lower_to_ebpf(&doc), lower_to_ebpf(&doc));
}
}