aa-gateway 0.0.1-beta.4

Control plane — policy enforcement engine and agent registry for Agent Assembly
//! Bridge from the gateway's rich [`PolicyDocument`] to the canonical,
//! cross-layer [`aa_security::policy::PolicyDocument`] (AAASM-3607).
//!
//! The canonical AST in `aa-security` is the single source of truth shared by
//! the gateway rule engine (L7) and the eBPF map compiler (kernel). The gateway
//! keeps its richer in-crate document for L7-only evaluation concerns (CEL
//! contexts, history stores, budget accounting), but it projects onto the
//! canonical AST here so the *exact same* typed definition feeds the kernel
//! lowering — there is no second, divergent copy of the shared dimensions.
//!
//! This is the mechanism that closes the schema-mismatch seam an attacker would
//! otherwise live in: the kernel rules are lowered (`aa_security::policy::
//! lower_to_ebpf`) from what this bridge produces, which is derived from the
//! same validated gateway document the L7 engine evaluates.

use aa_security::policy::{
    Capability as CanonCapability, CapabilitySet as CanonCapabilitySet, NetworkPolicy as CanonNetworkPolicy,
    PolicyDocument as CanonPolicyDocument, ToolRule as CanonToolRule,
};

use crate::policy::document::PolicyDocument;

/// Map an `aa_core::Capability` onto the canonical `aa_security` capability.
///
/// The two enums share an identical variant vocabulary; this is a total,
/// lossless mapping kept explicit so a future divergence is a compile error.
fn to_canon_capability(cap: &aa_core::Capability) -> CanonCapability {
    match cap {
        aa_core::Capability::FileRead => CanonCapability::FileRead,
        aa_core::Capability::FileWrite => CanonCapability::FileWrite,
        aa_core::Capability::NetworkOutbound => CanonCapability::NetworkOutbound,
        aa_core::Capability::NetworkInbound => CanonCapability::NetworkInbound,
        aa_core::Capability::TerminalExec => CanonCapability::TerminalExec,
        aa_core::Capability::McpTool(n) => CanonCapability::McpTool(n.clone()),
        aa_core::Capability::Model(n) => CanonCapability::Model(n.clone()),
        aa_core::Capability::AgentSpawn => CanonCapability::AgentSpawn,
    }
}

impl PolicyDocument {
    /// Project this validated gateway document onto the canonical, cross-layer
    /// [`aa_security::policy::PolicyDocument`].
    ///
    /// Only the shared dimensions (capabilities, network egress, tool rules)
    /// are carried over; L7-only sections (budget, schedule, data scanner,
    /// approval routing) are intentionally dropped — they are documented as
    /// L7-only carve-outs in `aa_security::policy::ebpf::L7_ONLY_DIMENSIONS`.
    pub fn to_canonical(&self) -> CanonPolicyDocument {
        let capabilities = self.capabilities.as_ref().map(|caps| {
            let mut set = CanonCapabilitySet::default();
            for c in &caps.allow {
                set.allow.insert(to_canon_capability(c));
            }
            for c in &caps.deny {
                set.deny.insert(to_canon_capability(c));
            }
            set
        });

        let network = self.network.as_ref().map(|n| CanonNetworkPolicy {
            allowlist: n.allowlist.clone(),
        });

        let mut tools: Vec<CanonToolRule> = self
            .tools
            .iter()
            .map(|(name, t)| CanonToolRule {
                name: name.clone(),
                allow: t.allow,
                requires_approval_if: t.requires_approval_if.clone(),
            })
            .collect();
        // HashMap iteration order is nondeterministic; sort so the canonical
        // projection (and the kernel rules lowered from it) are stable.
        tools.sort_by(|a, b| a.name.cmp(&b.name));

        CanonPolicyDocument {
            name: self.name.clone(),
            network,
            capabilities,
            tools,
            // The gateway's source PolicyDocument does not yet model a kernel
            // syscall allowlist; the syscall-allowlist node (AAASM-3624) is
            // populated from the policy YAML by aa-security's own parser.
            // Wiring the gateway's projection to it is future work.
            syscall_allowlist: None,
        }
    }
}

#[cfg(test)]
mod tests {
    use std::collections::HashMap;

    use aa_core::CapabilitySet;

    use super::*;
    use crate::policy::document::ToolPolicy;
    use crate::policy::scope::PolicyScope;

    fn base_doc() -> PolicyDocument {
        PolicyDocument {
            name: Some("t".to_string()),
            policy_version: None,
            version: None,
            scope: PolicyScope::Global,
            network: None,
            schedule: None,
            budget: None,
            data: None,
            approval_timeout_secs: 300,
            approval_policy: None,
            tools: HashMap::new(),
            capabilities: None,
        }
    }

    #[test]
    fn projects_capabilities() {
        let mut caps = CapabilitySet::default();
        caps.deny.insert(aa_core::Capability::FileWrite);
        caps.allow.insert(aa_core::Capability::FileRead);
        let mut doc = base_doc();
        doc.capabilities = Some(caps);

        let canon = doc.to_canonical();
        let cc = canon.capabilities.unwrap();
        assert!(cc.deny.contains(&CanonCapability::FileWrite));
        assert!(cc.allow.contains(&CanonCapability::FileRead));
    }

    #[test]
    fn projects_network_and_sorted_tools() {
        let mut doc = base_doc();
        doc.network = Some(crate::policy::document::NetworkPolicy {
            allowlist: vec!["api.openai.com".to_string()],
        });
        doc.tools.insert(
            "zebra".to_string(),
            ToolPolicy {
                allow: true,
                limit_per_hour: None,
                requires_approval_if: None,
            },
        );
        doc.tools.insert(
            "alpha".to_string(),
            ToolPolicy {
                allow: false,
                limit_per_hour: None,
                requires_approval_if: Some("path starts_with \"/etc\"".to_string()),
            },
        );

        let canon = doc.to_canonical();
        assert_eq!(canon.egress_allowlist(), ["api.openai.com"]);
        // deterministic order
        assert_eq!(canon.tools[0].name, "alpha");
        assert_eq!(canon.tools[1].name, "zebra");
        assert_eq!(
            canon.tools[0].requires_approval_if.as_deref(),
            Some("path starts_with \"/etc\"")
        );
    }

    #[test]
    fn canonical_lowers_to_ebpf_rules() {
        // Proves the same gateway document feeds the kernel lowering.
        let mut caps = CapabilitySet::default();
        caps.deny.insert(aa_core::Capability::FileWrite);
        let mut doc = base_doc();
        doc.capabilities = Some(caps);

        let rules = aa_security::policy::lower_to_ebpf(&doc.to_canonical());
        assert!(rules.deny_paths().any(|p| p == "/etc"));
    }
}