cageforge-policy-compose 0.7.0

Policy ceiling composition for Rust process sandboxes
Documentation
// SPDX-License-Identifier: Apache-2.0

//! The pure [`crate::compose`] operation and effective decision queries.
//!
//! This module joins the public request and ceiling models. It does not own
//! configuration parsing or native capability detection; those remain in the
//! `cageforge-config` crate and the backend integration respectively.

use std::net::{IpAddr, SocketAddr};
use std::path::{Path, PathBuf};

use cageforge_policy::{
    AccessMode, ConnectionAuthorization, FilesystemDecision, FilesystemMode, NetworkDecision,
    NetworkMode, PathSelector, ResolvedNetworkTarget,
};

use crate::context::{ContextIdentity, EffectivePathContext};
use crate::environment::EffectiveEnvironment;
use crate::error::{CompositionBoundary, CompositionError};
use crate::filesystem::EffectiveFilesystemPolicy;
use crate::model::{
    CompositionRequest, EffectiveNetworkPolicy, EffectiveSandbox, normalize_roots, root_is_within,
};
use crate::ownership::ExternalOwner;

struct ComposedWorkspaceRoots {
    selected: Option<Vec<PathBuf>>,
    limit: Option<Vec<PathBuf>>,
}

trait EnforcementMode {
    fn delegates_enforcement(self) -> bool;
}

/// Computes a safe effective sandbox from a requested policy and a ceiling.
///
/// Every filesystem and network decision must be permitted by both inputs.
/// The environment is narrowed in sequence and the inherited base is reduced
/// to the least permissive choice. An external owner can only be composed with
/// another external owner; silently combining external and local ownership
/// would make the enforcement boundary ambiguous.
pub fn compose(request: CompositionRequest<'_>) -> Result<EffectiveSandbox, CompositionError> {
    let requested_policy = request
        .requested_policy
        .normalized()
        .map_err(|source| CompositionError::InvalidRequestedPolicy { source })?;
    let ceiling_policy = request
        .ceiling
        .policy()
        .normalized()
        .map_err(|source| CompositionError::InvalidCeiling { source })?;
    validate_ownership(
        requested_policy.filesystem().mode(),
        ceiling_policy.filesystem().mode(),
        CompositionBoundary::Filesystem,
    )?;
    validate_ownership(
        requested_policy.network().mode(),
        ceiling_policy.network().mode(),
        CompositionBoundary::Network,
    )?;
    validate_external_owner(
        requested_policy.filesystem().mode() == FilesystemMode::External,
        request.external_owner.as_ref(),
        request.ceiling.external_owner(),
        CompositionBoundary::Filesystem,
    )?;
    validate_external_owner(
        requested_policy.network().mode() == NetworkMode::External,
        request.external_owner.as_ref(),
        request.ceiling.external_owner(),
        CompositionBoundary::Network,
    )?;
    if requested_policy.filesystem().mode() != FilesystemMode::External
        && requested_policy.network().mode() != NetworkMode::External
        && (request.external_owner.is_some() || request.ceiling.external_owner().is_some())
    {
        return Err(CompositionError::UnexpectedExternalOwner {
            boundary: CompositionBoundary::Filesystem,
        });
    }

    let workspace_roots = compose_workspace_roots(
        request.requested_workspace_roots.as_deref(),
        request.ceiling.workspace_roots(),
    )?;
    let context_identity = ContextIdentity::new();

    Ok(EffectiveSandbox::new(
        EffectiveFilesystemPolicy::new(
            requested_policy.filesystem().clone(),
            ceiling_policy.filesystem().clone(),
            context_identity,
        ),
        EffectiveNetworkPolicy::new(
            requested_policy.network().clone(),
            ceiling_policy.network().clone(),
        ),
        EffectiveEnvironment::new(
            request.requested_environment.clone(),
            request.ceiling.environment().clone(),
        ),
        workspace_roots.selected,
        workspace_roots.limit,
    ))
}

impl EffectiveFilesystemPolicy {
    /// Evaluates a symbolic filesystem selector against both policies.
    ///
    /// The selector is resolved only through the effective runtime context.
    /// This prevents a workspace-root ceiling from being replaced by a broader
    /// caller-owned context. A selector that resolves to no paths is denied;
    /// external enforcement remains external without requiring local paths.
    pub fn access_for(
        &self,
        selector: &PathSelector,
        context: &EffectivePathContext,
    ) -> Result<FilesystemDecision, CompositionError> {
        if !self.owns_context(context) {
            return Err(CompositionError::PathContextMismatch);
        }
        if self.requested_policy().mode() == FilesystemMode::External
            && self.ceiling_policy().mode() == FilesystemMode::External
        {
            return Ok(FilesystemDecision::ExternallyEnforced);
        }

        let mut result = None;
        for path in context.resolve(selector) {
            let decision = self.access_for_path(&path, context)?;
            result = Some(match result {
                Some(previous) => combine_filesystem_decisions(previous, decision),
                None => decision,
            });
        }
        Ok(result.unwrap_or(FilesystemDecision::Deny))
    }

    /// Evaluates a concrete filesystem path against both policies.
    pub fn access_for_path(
        &self,
        path: &Path,
        context: &EffectivePathContext,
    ) -> Result<FilesystemDecision, CompositionError> {
        if !self.owns_context(context) {
            return Err(CompositionError::PathContextMismatch);
        }
        let requested = self
            .requested_policy()
            .access_for_path(path, context.raw())
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Filesystem,
                source,
            })?;
        let ceiling = self
            .ceiling_policy()
            .access_for_path(path, context.raw())
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Filesystem,
                source,
            })?;
        Ok(combine_filesystem_decisions(requested, ceiling))
    }
}

impl EffectiveNetworkPolicy {
    /// Evaluates a domain against both policies.
    pub fn decision_for_domain(&self, domain: &str) -> Result<NetworkDecision, CompositionError> {
        let requested = self
            .requested_policy()
            .decision_for_domain(domain)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        let ceiling = self
            .ceiling_policy()
            .decision_for_domain(domain)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        Ok(combine_network_decisions(requested, ceiling))
    }

    /// Evaluates a domain and all addresses resolved for it against both
    /// policies without authorizing a later connection.
    pub fn decision_for_domain_with_resolved_ips(
        &self,
        domain: &str,
        resolved_ips: &[IpAddr],
    ) -> Result<NetworkDecision, CompositionError> {
        let requested = self
            .requested_policy()
            .decision_for_domain_with_resolved_ips(domain, resolved_ips)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        let ceiling = self
            .ceiling_policy()
            .decision_for_domain_with_resolved_ips(domain, resolved_ips)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        Ok(combine_network_decisions(requested, ceiling))
    }

    /// Authorizes the exact socket address a backend is about to connect to.
    ///
    /// The returned allowed value contains the address that passed both
    /// policies and belongs to the target's resolution snapshot. Backends
    /// must connect using that value and must not resolve the hostname again.
    pub fn authorize_connection(
        &self,
        target: &ResolvedNetworkTarget,
        connected: SocketAddr,
    ) -> Result<ConnectionAuthorization, CompositionError> {
        let requested = self
            .requested_policy()
            .authorize_connection(target, connected)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        let ceiling = self
            .ceiling_policy()
            .authorize_connection(target, connected)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        Ok(combine_connection_authorizations(requested, ceiling))
    }

    /// Evaluates a Unix socket against both policies.
    pub fn decision_for_unix_socket(
        &self,
        socket: &Path,
    ) -> Result<NetworkDecision, CompositionError> {
        let requested = self
            .requested_policy()
            .decision_for_unix_socket(socket)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        let ceiling = self
            .ceiling_policy()
            .decision_for_unix_socket(socket)
            .map_err(|source| CompositionError::PolicyEvaluation {
                boundary: CompositionBoundary::Network,
                source,
            })?;
        Ok(combine_network_decisions(requested, ceiling))
    }
}

fn validate_ownership(
    requested: impl EnforcementMode,
    ceiling: impl EnforcementMode,
    boundary: CompositionBoundary,
) -> Result<(), CompositionError> {
    if requested.delegates_enforcement() != ceiling.delegates_enforcement() {
        return Err(CompositionError::EnforcementOwnershipConflict { boundary });
    }
    Ok(())
}

fn validate_external_owner(
    external: bool,
    requested_owner: Option<&ExternalOwner>,
    ceiling_owner: Option<&ExternalOwner>,
    boundary: CompositionBoundary,
) -> Result<(), CompositionError> {
    if external && (requested_owner != ceiling_owner || requested_owner.is_none()) {
        return Err(CompositionError::ExternalOwnerMismatch { boundary });
    }
    Ok(())
}

impl EnforcementMode for FilesystemMode {
    fn delegates_enforcement(self) -> bool {
        self == Self::External
    }
}

impl EnforcementMode for NetworkMode {
    fn delegates_enforcement(self) -> bool {
        self == Self::External
    }
}

fn combine_filesystem_decisions(
    requested: FilesystemDecision,
    ceiling: FilesystemDecision,
) -> FilesystemDecision {
    match (requested, ceiling) {
        (FilesystemDecision::ExternallyEnforced, FilesystemDecision::ExternallyEnforced) => {
            FilesystemDecision::ExternallyEnforced
        }
        (FilesystemDecision::ExternallyEnforced, _)
        | (_, FilesystemDecision::ExternallyEnforced) => FilesystemDecision::Deny,
        (left, right) => match (left.as_access_mode(), right.as_access_mode()) {
            (Some(left), Some(right)) => AccessMode::most_restrictive(left, right).into(),
            _ => FilesystemDecision::Deny,
        },
    }
}

fn combine_network_decisions(
    requested: NetworkDecision,
    ceiling: NetworkDecision,
) -> NetworkDecision {
    match (requested, ceiling) {
        (NetworkDecision::ExternallyEnforced, NetworkDecision::ExternallyEnforced) => {
            NetworkDecision::ExternallyEnforced
        }
        (NetworkDecision::ExternallyEnforced, _) | (_, NetworkDecision::ExternallyEnforced) => {
            NetworkDecision::Deny
        }
        (NetworkDecision::Allow, NetworkDecision::Allow) => NetworkDecision::Allow,
        _ => NetworkDecision::Deny,
    }
}

fn combine_connection_authorizations(
    requested: ConnectionAuthorization,
    ceiling: ConnectionAuthorization,
) -> ConnectionAuthorization {
    match (requested, ceiling) {
        (ConnectionAuthorization::Allowed(requested), ConnectionAuthorization::Allowed(_)) => {
            ConnectionAuthorization::Allowed(requested)
        }
        (
            ConnectionAuthorization::ExternallyEnforced,
            ConnectionAuthorization::ExternallyEnforced,
        ) => ConnectionAuthorization::ExternallyEnforced,
        _ => ConnectionAuthorization::Denied,
    }
}

fn compose_workspace_roots(
    requested_roots: Option<&[PathBuf]>,
    ceiling_roots: Option<&[PathBuf]>,
) -> Result<ComposedWorkspaceRoots, CompositionError> {
    let requested_roots = requested_roots
        .map(|roots| normalize_roots(roots.iter().cloned()))
        .transpose()?;
    let ceiling_roots = ceiling_roots
        .map(|roots| normalize_roots(roots.iter().cloned()))
        .transpose()?;

    match (requested_roots, ceiling_roots) {
        (Some(requested), Some(ceiling)) => {
            for root in &requested {
                if !root_is_within(root, &ceiling) {
                    return Err(CompositionError::WorkspaceRootNotGranted { path: root.clone() });
                }
            }
            Ok(ComposedWorkspaceRoots {
                selected: Some(requested),
                limit: Some(ceiling),
            })
        }
        (Some(requested), None) => Ok(ComposedWorkspaceRoots {
            selected: Some(requested),
            limit: None,
        }),
        (None, Some(ceiling)) => Ok(ComposedWorkspaceRoots {
            selected: None,
            limit: Some(ceiling),
        }),
        (None, None) => Ok(ComposedWorkspaceRoots {
            selected: None,
            limit: None,
        }),
    }
}