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