Skip to main content

aa_security/policy/
document.rs

1//! The canonical, cross-layer policy AST.
2//!
3//! [`PolicyDocument`] is the single typed source of truth that both the
4//! gateway rule engine (L7) and the eBPF map compiler (kernel) consume, so a
5//! policy is described exactly once and the two enforcement layers cannot
6//! drift apart. See AAASM-3606 / AAASM-3561.
7//!
8//! This is deliberately the *canonical* (lowering-relevant) shape: the
9//! filesystem/capability and network-egress dimensions that the kernel layer
10//! can enforce, plus the tool-path predicates needed to derive in-kernel path
11//! rules. Gateway-only evaluation concerns (history stores, CEL contexts,
12//! budget accounting) stay in `aa-gateway` and operate *on top of* this AST.
13
14use super::capability::CapabilitySet;
15use super::syscall::SyscallAllowlist;
16
17/// Network egress policy: the set of hosts an agent may connect to.
18///
19/// An empty (or absent) allowlist means "no egress restriction" — matching the
20/// documented semantics in `policy-examples/strict.yaml`.
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct NetworkPolicy {
24    /// Host glob patterns the agent may connect to.
25    pub allowlist: Vec<String>,
26}
27
28/// A single tool rule from the policy `tools:` map.
29///
30/// Only the fields needed for cross-layer lowering + L7 evaluation are kept on
31/// the canonical AST. `requires_approval_if` is preserved verbatim so the
32/// eBPF lowering can extract the `path starts_with "…"` predicates that map to
33/// in-kernel `PathPattern` deny rules.
34#[derive(Debug, Clone, PartialEq, Eq)]
35#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
36pub struct ToolRule {
37    /// Tool name (map key), e.g. `write_file` or the `*` wildcard.
38    pub name: String,
39    /// Whether the tool is permitted.
40    pub allow: bool,
41    /// Raw CEL-ish `requires_approval_if` expression, if present.
42    pub requires_approval_if: Option<String>,
43}
44
45/// The canonical policy document shared across enforcement layers.
46#[derive(Debug, Clone, PartialEq, Eq, Default)]
47#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
48pub struct PolicyDocument {
49    /// Human-readable policy name (`metadata.name`).
50    pub name: Option<String>,
51    /// Network egress policy.
52    pub network: Option<NetworkPolicy>,
53    /// Capability allow/deny floor for this policy scope.
54    pub capabilities: Option<CapabilitySet>,
55    /// Per-tool rules, in declaration order.
56    pub tools: Vec<ToolRule>,
57    /// Kernel syscall allowlist for this workload (AAASM-3624). When set, the
58    /// eBPF enforcement probe default-denies any syscall not listed for a
59    /// monitored PID. Lowered to `SYSCALL_ALLOWLIST` map entries by
60    /// AAASM-3635.
61    pub syscall_allowlist: Option<SyscallAllowlist>,
62}
63
64impl PolicyDocument {
65    /// Return the capability deny set, or an empty slice if unset.
66    pub fn denied_capabilities(&self) -> Vec<&super::capability::Capability> {
67        self.capabilities
68            .as_ref()
69            .map(|c| c.deny.iter().collect())
70            .unwrap_or_default()
71    }
72
73    /// Return the network egress allowlist, or an empty slice if unset.
74    pub fn egress_allowlist(&self) -> &[String] {
75        self.network.as_ref().map(|n| n.allowlist.as_slice()).unwrap_or(&[])
76    }
77
78    /// Return the permitted syscalls, or an empty iterator if no syscall
79    /// allowlist is set.
80    pub fn allowed_syscalls(&self) -> Vec<super::syscall::Syscall> {
81        self.syscall_allowlist
82            .as_ref()
83            .map(|a| a.iter().collect())
84            .unwrap_or_default()
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::super::capability::Capability;
91    use super::*;
92
93    #[test]
94    fn default_document_is_empty() {
95        let doc = PolicyDocument::default();
96        assert!(doc.name.is_none());
97        assert!(doc.network.is_none());
98        assert!(doc.capabilities.is_none());
99        assert!(doc.tools.is_empty());
100        assert!(doc.denied_capabilities().is_empty());
101        assert!(doc.egress_allowlist().is_empty());
102        assert!(doc.syscall_allowlist.is_none());
103        assert!(doc.allowed_syscalls().is_empty());
104    }
105
106    #[test]
107    fn accessors_read_through() {
108        use super::super::syscall::{Syscall, SyscallAllowlist};
109        let mut caps = CapabilitySet::default();
110        caps.deny.insert(Capability::FileWrite);
111        let doc = PolicyDocument {
112            name: Some("strict".to_string()),
113            network: Some(NetworkPolicy {
114                allowlist: vec!["api.openai.com".to_string()],
115            }),
116            capabilities: Some(caps),
117            tools: vec![ToolRule {
118                name: "write_file".to_string(),
119                allow: false,
120                requires_approval_if: None,
121            }],
122            syscall_allowlist: Some(SyscallAllowlist::from_names(["read", "write"]).unwrap()),
123        };
124        assert_eq!(doc.denied_capabilities(), vec![&Capability::FileWrite]);
125        assert_eq!(doc.egress_allowlist(), ["api.openai.com"]);
126        assert_eq!(doc.tools.len(), 1);
127        assert_eq!(doc.allowed_syscalls(), vec![Syscall::Read, Syscall::Write]);
128    }
129}