Skip to main content

aa_security/policy/
ebpf.rs

1//! Deterministic lowering of the canonical [`PolicyDocument`] into the flat
2//! rule set the eBPF maps consume (AAASM-3608).
3//!
4//! The kernel layer is *generated from* the same policy the gateway enforces,
5//! never hand-maintained — this is the mechanism behind "one policy source →
6//! gateway + eBPF rules with no detectable enforcement gap". The privileged
7//! loader daemon (AAASM-3603/3604) pushes the produced [`EbpfRuleSet`] into the
8//! `PATH_BLOCKLIST` / `PATH_ALLOWLIST` BPF maps over the control channel.
9//!
10//! # What is enforceable in-kernel vs L7-only
11//!
12//! The eBPF probes match on **filesystem paths** and (where wired) **egress
13//! hosts**. The lowering therefore covers exactly:
14//!
15//! - **Filesystem path rules** derived from `tools.*.requires_approval_if`
16//!   predicates of the form `path starts_with "<prefix>"` (each becomes a
17//!   [`PathVerdict::Deny`] [`PathRule`]) and from a capability `file_write`
18//!   deny (which seeds the well-known sensitive-path deny defaults).
19//! - **Egress allowlist** copied verbatim from `network.allowlist`.
20//!
21//! Everything else is explicitly **L7-only** and documented in
22//! [`L7_ONLY_DIMENSIONS`] so the cross-layer consistency test (AAASM-3609) can
23//! assert the gap is intentional and reviewed, never silent:
24//!
25//! - Budget / spend limits (`budget`)
26//! - Schedule / active-hours (`schedule`)
27//! - Credential / PII scanning + redaction (`data`)
28//! - Per-tool rate limits (`tools.*.limit_per_hour`)
29//! - Non-path CEL predicates (`url contains`, `args.*`, `tool_result.*`)
30//! - Capability categories with no path/host projection (`agent_spawn`,
31//!   `model:*`, `mcp_tool:*`, `network_inbound`, `terminal_exec`)
32
33use super::capability::Capability;
34use super::document::PolicyDocument;
35
36/// Policy dimensions that are structurally **not** enforceable by the eBPF
37/// path/egress probes and are therefore enforced only at L7 by the gateway.
38///
39/// The cross-layer consistency test asserts every divergence between the two
40/// layers falls under one of these documented carve-outs.
41pub 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/// Whether matching a path rule allows or denies the operation in-kernel.
55///
56/// Mirrors `aa_ebpf::maps::PathVerdict`; kept here so the leaf crate has no
57/// dependency on `aa-ebpf` (the dependency points the other way — the loader
58/// daemon consumes this crate).
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
61pub enum PathVerdict {
62    /// The path is allowed — no policy violation.
63    Allow,
64    /// The path is blocked — triggers a policy violation event.
65    Deny,
66}
67
68/// A single lowered filesystem path rule destined for a BPF path map.
69///
70/// `pattern` is a path prefix (e.g. `/etc`); the kernel probe flags any access
71/// whose path starts with it.
72#[derive(Debug, Clone, PartialEq, Eq)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74pub struct PathRule {
75    /// Path prefix to match.
76    pub pattern: String,
77    /// Verdict applied on match.
78    pub verdict: PathVerdict,
79}
80
81/// The flat rule set produced by [`lower_to_ebpf`], consumed by the loader
82/// daemon's map-update path.
83#[derive(Debug, Clone, PartialEq, Eq, Default)]
84#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
85pub struct EbpfRuleSet {
86    /// Filesystem path rules for `PATH_BLOCKLIST` / `PATH_ALLOWLIST`.
87    pub path_rules: Vec<PathRule>,
88    /// Egress host allowlist (empty means "no egress restriction").
89    pub egress_allowlist: Vec<String>,
90    /// Permitted syscall numbers for the `SYSCALL_ALLOWLIST` BPF map
91    /// (AAASM-3635). Empty means "no syscall allowlist" — the enforcement
92    /// probe leaves a monitored PID's syscalls unconstrained. Order-stable
93    /// (ascending by the AST's `BTreeSet` order) so the lowering is
94    /// deterministic.
95    pub syscall_allowlist: Vec<u32>,
96}
97
98impl EbpfRuleSet {
99    /// Convenience: the deny path patterns, in order.
100    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
108/// Well-known sensitive paths denied in-kernel whenever the policy denies the
109/// `file_write` capability. These mirror the kernel probe's sensitive-path
110/// defaults so a "deny file_write" floor is reflected at the kernel boundary,
111/// not just at L7.
112const SENSITIVE_WRITE_DENY_DEFAULTS: &[&str] = &["/etc", "/root/.ssh", "/var/run/secrets"];
113
114/// Lower a canonical [`PolicyDocument`] to its [`EbpfRuleSet`].
115///
116/// Deterministic and pure: the same document always lowers to the same rule
117/// set (path rules are de-duplicated and order-stable).
118pub fn lower_to_ebpf(doc: &PolicyDocument) -> EbpfRuleSet {
119    let mut path_rules: Vec<PathRule> = Vec::new();
120
121    // 1. Capability `file_write` deny → seed the sensitive-path deny defaults.
122    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    // 2. Tool `requires_approval_if` path predicates → explicit deny rules.
140    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
161/// Lower the [`SyscallAllowlist`](super::syscall::SyscallAllowlist) node to the
162/// flat list of syscall numbers the `SYSCALL_ALLOWLIST` BPF map consumes
163/// (AAASM-3635).
164///
165/// Reuses the same single `lower_to_ebpf` pipeline as the path/egress lowering
166/// — there is no second policy or lowering path. Deterministic: the AST's
167/// `BTreeSet` ordering makes the emitted numbers order-stable, and an absent
168/// node lowers to an empty list (no syscall constraint).
169fn 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
176/// Push a rule only if no rule with the same pattern+verdict already exists,
177/// preserving insertion order for determinism.
178fn push_unique(rules: &mut Vec<PathRule>, rule: PathRule) {
179    if !rules.contains(&rule) {
180        rules.push(rule);
181    }
182}
183
184/// Extract path prefixes from a `requires_approval_if` predicate of the form
185/// `path starts_with "<prefix>"` (case-sensitive on the operator). Returns all
186/// matches in the expression so compound `… AND path starts_with "…"` clauses
187/// are covered. Non-path predicates yield nothing (they are L7-only).
188fn 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
202/// Return the contents of the first double-quoted literal in `s`, if any.
203fn 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        // read=0, write=1, close=3, exit=60 — ordered by the BTreeSet's enum
313        // declaration order (Read, Write, Close, Exit).
314        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}