nono-cli 0.63.0

CLI for nono capability-based sandbox
use crate::capability_ext::CapabilitySetExt;
use crate::cli::{SandboxArgs, WhyArgs, WhyOp, WhyScope};
use crate::query_ext::ScopeQuery;
use crate::{network_policy, policy, profile, query_ext, sandbox_state};
use nono::{AccessMode, CapabilitySet, NonoError, Result};

struct WhyContext {
    caps: CapabilitySet,
    overridden_paths: Vec<std::path::PathBuf>,
    allowed_domains: Vec<String>,
    domain_endpoints: Vec<sandbox_state::DomainEndpointState>,
}

/// Resolve the proxy domain allowlist from a profile's network config.
fn resolve_allowed_domains(profile: &profile::Profile) -> Vec<String> {
    let policy_json = crate::config::embedded::embedded_network_policy_json();
    let net_policy = match network_policy::load_network_policy(policy_json) {
        Ok(p) => p,
        Err(_) => {
            return profile
                .network
                .allow_domain
                .iter()
                .map(|e| e.domain().to_string())
                .collect();
        }
    };

    let mut domains = Vec::new();

    if let Some(net_profile_name) = profile.network.resolved_network_profile()
        && let Ok(resolved) = network_policy::resolve_network_profile(&net_policy, net_profile_name)
    {
        domains.extend(resolved.hosts);
        for suffix in &resolved.suffixes {
            let wildcard = if suffix.starts_with('.') {
                format!("*{}", suffix)
            } else {
                format!("*.{}", suffix)
            };
            domains.push(wildcard);
        }
    }

    let plain_entries: Vec<String> = profile
        .network
        .allow_domain
        .iter()
        .map(|e| e.domain().to_string())
        .collect();
    domains.extend(network_policy::expand_proxy_allow(
        &net_policy,
        &plain_entries,
    ));

    domains
}

/// Extract domain endpoint restrictions from a profile's allow_domain entries.
fn resolve_domain_endpoints(profile: &profile::Profile) -> Vec<sandbox_state::DomainEndpointState> {
    profile
        .network
        .allow_domain
        .iter()
        .filter_map(|e| match e {
            profile::AllowDomainEntry::WithEndpoints { domain, endpoints }
                if !endpoints.is_empty() =>
            {
                Some(sandbox_state::DomainEndpointState {
                    domain: domain.clone(),
                    endpoints: endpoints
                        .iter()
                        .map(|r| sandbox_state::EndpointRuleState {
                            method: r.method.clone(),
                            path: r.path.clone(),
                        })
                        .collect(),
                })
            }
            _ => None,
        })
        .collect()
}

pub(crate) fn run_why(args: WhyArgs) -> Result<()> {
    use query_ext::{QueryResult, print_result, query_network, query_path, query_scope};
    use sandbox_state::load_sandbox_state;

    let ctx: WhyContext = if args.self_query {
        match load_sandbox_state() {
            Some(state) => {
                let paths = state.bypass_protection_as_paths();
                let domain_endpoints = state.domain_endpoints.clone();
                WhyContext {
                    caps: state.to_caps()?,
                    overridden_paths: paths,
                    allowed_domains: state.allowed_domains.clone(),
                    domain_endpoints,
                }
            }
            None => {
                let result = QueryResult::NotSandboxed {
                    message: "Not running inside a nono sandbox".to_string(),
                };
                if args.json {
                    let json = serde_json::to_string_pretty(&result).map_err(|e| {
                        NonoError::ConfigParse(format!("JSON serialization failed: {}", e))
                    })?;
                    println!("{}", json);
                } else {
                    print_result(&result);
                }
                return Ok(());
            }
        }
    } else if let Some(ref profile_name) = args.profile {
        let profile = profile::load_profile(profile_name)?;
        let workdir = args
            .workdir
            .clone()
            .or_else(|| std::env::current_dir().ok())
            .unwrap_or_else(|| std::path::PathBuf::from("."));

        let sandbox_args = SandboxArgs {
            allow: args.allow.clone(),
            read: args.read.clone(),
            write: args.write.clone(),
            allow_file: args.allow_file.clone(),
            read_file: args.read_file.clone(),
            write_file: args.write_file.clone(),
            block_net: args.block_net,
            workdir: args.workdir.clone(),
            ..SandboxArgs::default()
        };

        let mut override_paths = Vec::new();
        for tmpl in &profile.filesystem.bypass_protection {
            let expanded = profile::expand_vars(tmpl, &workdir)?;
            if expanded.exists() {
                if let Ok(canonical) = expanded.canonicalize() {
                    override_paths.push(canonical);
                }
            } else {
                override_paths.push(expanded);
            }
        }

        let allowed_domains = resolve_allowed_domains(&profile);
        let domain_endpoints = resolve_domain_endpoints(&profile);

        let prepared = CapabilitySet::from_profile(&profile, &workdir, &sandbox_args)?;
        let mut caps = prepared.caps;
        if prepared.needs_unlink_overrides {
            policy::apply_unlink_overrides(&mut caps);
        }
        WhyContext {
            caps,
            overridden_paths: override_paths,
            allowed_domains,
            domain_endpoints,
        }
    } else {
        let sandbox_args = SandboxArgs {
            allow: args.allow.clone(),
            read: args.read.clone(),
            write: args.write.clone(),
            allow_file: args.allow_file.clone(),
            read_file: args.read_file.clone(),
            write_file: args.write_file.clone(),
            block_net: args.block_net,
            workdir: args.workdir.clone(),
            ..SandboxArgs::default()
        };

        let prepared = CapabilitySet::from_args(&sandbox_args)?;
        let mut caps = prepared.caps;
        if prepared.needs_unlink_overrides {
            policy::apply_unlink_overrides(&mut caps);
        }
        WhyContext {
            caps,
            overridden_paths: vec![],
            allowed_domains: vec![],
            domain_endpoints: vec![],
        }
    };

    let result = if let Some(ref path) = args.path {
        let op = match args.op {
            Some(WhyOp::Read) => AccessMode::Read,
            Some(WhyOp::Write) => AccessMode::Write,
            Some(WhyOp::ReadWrite) => AccessMode::ReadWrite,
            None => AccessMode::Read,
        };
        query_path(path, op, &ctx.caps, &ctx.overridden_paths)?
    } else if let Some(ref host) = args.host {
        query_network(
            host,
            args.port,
            &ctx.caps,
            &ctx.allowed_domains,
            &ctx.domain_endpoints,
        )
    } else if let Some(ref scope) = args.scope {
        query_scope(scope_query(scope), &ctx.caps)
    } else {
        return Err(NonoError::ConfigParse(
            "--path, --host, or --scope is required".to_string(),
        ));
    };

    if args.json {
        let json = serde_json::to_string_pretty(&result)
            .map_err(|e| NonoError::ConfigParse(format!("JSON serialization failed: {}", e)))?;
        println!("{}", json);
    } else {
        print_result(&result);
    }

    Ok(())
}

fn scope_query(scope: &WhyScope) -> ScopeQuery {
    match scope {
        WhyScope::Signal => ScopeQuery::Signal,
        WhyScope::AbstractUnixSocket => ScopeQuery::AbstractUnixSocket,
    }
}