runx-core 0.6.19

Pure Rust parity kernel for runx state-machine and policy decisions.
Documentation
use super::{
    CwdPolicy, RequiredSandboxDeclaration, SandboxAdmissionDecision, SandboxAdmissionOptions,
    SandboxDeclaration, SandboxProfile,
};

#[must_use]
pub fn normalize_sandbox_declaration(
    sandbox: Option<&SandboxDeclaration>,
) -> RequiredSandboxDeclaration {
    let Some(sandbox) = sandbox else {
        return RequiredSandboxDeclaration {
            profile: SandboxProfile::Readonly,
            cwd_policy: CwdPolicy::SkillDirectory,
            env_allowlist: None,
            network: false,
            writable_paths: Vec::new(),
            require_enforcement: true,
        };
    };

    RequiredSandboxDeclaration {
        profile: sandbox.profile.clone(),
        cwd_policy: sandbox
            .cwd_policy
            .clone()
            .unwrap_or(CwdPolicy::SkillDirectory),
        env_allowlist: sandbox.env_allowlist.clone(),
        network: sandbox
            .network
            .unwrap_or(matches!(sandbox.profile, SandboxProfile::Network)),
        writable_paths: sandbox.writable_paths.clone().unwrap_or_default(),
        require_enforcement: sandbox.require_enforcement.unwrap_or(!matches!(
            sandbox.profile,
            SandboxProfile::UnrestrictedLocalDev
        )),
    }
}

#[must_use]
pub fn sandbox_requires_approval(sandbox: Option<&SandboxDeclaration>) -> bool {
    matches!(
        normalize_sandbox_declaration(sandbox).profile,
        SandboxProfile::UnrestrictedLocalDev
    )
}

#[must_use]
pub fn is_reserved_runx_sandbox_env_name(name: &str) -> bool {
    let upper = name.to_ascii_uppercase();
    if upper.starts_with("RUNX_RECEIPT_SIGN_") {
        return true;
    }
    if !upper.starts_with("RUNX_") {
        return false;
    }
    [
        "SECRET",
        "TOKEN",
        "PASSWORD",
        "API_KEY",
        "PRIVATE_KEY",
        "ACCESS_KEY",
        "SIGNING_KEY",
        "CREDENTIAL",
        "SEED",
    ]
    .iter()
    .any(|needle| upper.contains(needle))
}

#[must_use]
pub fn admit_sandbox(
    sandbox: Option<&SandboxDeclaration>,
    options: &SandboxAdmissionOptions,
) -> SandboxAdmissionDecision {
    let declaration = normalize_sandbox_declaration(sandbox);
    let mut reasons = Vec::new();

    collect_profile_violations(&declaration, &mut reasons);

    if !reasons.is_empty() {
        return SandboxAdmissionDecision::Deny { reasons };
    }

    if requires_unapproved_escalation(&declaration, options) {
        return SandboxAdmissionDecision::ApprovalRequired {
            reasons: vec![
                "unrestricted-local-dev sandbox requires explicit caller approval".to_owned(),
            ],
        };
    }

    SandboxAdmissionDecision::Allow {
        reasons: vec![format!(
            "sandbox profile '{}' admitted",
            sandbox_profile_name(&declaration.profile)
        )],
    }
}

fn collect_profile_violations(declaration: &RequiredSandboxDeclaration, reasons: &mut Vec<String>) {
    collect_reserved_env_allowlist_violations(declaration, reasons);

    if matches!(declaration.profile, SandboxProfile::Readonly) {
        if !declaration.writable_paths.is_empty() {
            reasons.push("readonly sandbox cannot declare writable paths".to_owned());
        }
        if declaration.network {
            reasons.push("readonly sandbox cannot declare network access".to_owned());
        }
    }

    if matches!(declaration.profile, SandboxProfile::WorkspaceWrite) {
        collect_unsafe_writable_paths(declaration, reasons);
    }

    if matches!(declaration.profile, SandboxProfile::Network)
        && !declaration.writable_paths.is_empty()
    {
        reasons.push("network sandbox cannot declare writable paths; use unrestricted-local-dev for combined local write and network access".to_owned());
    }
}

fn collect_reserved_env_allowlist_violations(
    declaration: &RequiredSandboxDeclaration,
    reasons: &mut Vec<String>,
) {
    let Some(env_allowlist) = declaration.env_allowlist.as_ref() else {
        return;
    };
    let denied = env_allowlist
        .iter()
        .filter(|name| is_reserved_runx_sandbox_env_name(name))
        .cloned()
        .collect::<Vec<_>>();
    if denied.is_empty() {
        return;
    }
    reasons.push(format!(
        "sandbox env_allowlist contains reserved runx environment variable(s): {}",
        denied.join(", ")
    ));
}

fn collect_unsafe_writable_paths(
    declaration: &RequiredSandboxDeclaration,
    reasons: &mut Vec<String>,
) {
    let unsafe_paths = declaration
        .writable_paths
        .iter()
        .filter(|path| is_unsafe_writable_path(path))
        .cloned()
        .collect::<Vec<_>>();

    if !unsafe_paths.is_empty() {
        reasons.push(format!(
            "workspace-write sandbox has unsafe writable path(s): {}",
            unsafe_paths.join(", ")
        ));
    }
}

fn requires_unapproved_escalation(
    declaration: &RequiredSandboxDeclaration,
    options: &SandboxAdmissionOptions,
) -> bool {
    matches!(declaration.profile, SandboxProfile::UnrestrictedLocalDev)
        && !options.approved_escalation.unwrap_or(false)
        && !options.skip_escalation.unwrap_or(false)
}

fn is_unsafe_writable_path(value: &str) -> bool {
    value.is_empty() || value.split(['/', '\\']).any(|segment| segment == "..")
}

fn sandbox_profile_name(profile: &SandboxProfile) -> &'static str {
    match profile {
        SandboxProfile::Readonly => "readonly",
        SandboxProfile::WorkspaceWrite => "workspace-write",
        SandboxProfile::Network => "network",
        SandboxProfile::UnrestrictedLocalDev => "unrestricted-local-dev",
    }
}

#[cfg(test)]
mod tests {
    use super::{admit_sandbox, is_reserved_runx_sandbox_env_name, normalize_sandbox_declaration};
    use crate::policy::{
        SandboxAdmissionDecision, SandboxAdmissionOptions, SandboxDeclaration, SandboxProfile,
    };

    #[test]
    fn normalize_defaults_match_typescript() {
        let declaration = normalize_sandbox_declaration(None);

        assert_eq!(declaration.profile, SandboxProfile::Readonly);
        assert!(!declaration.network);
        assert!(declaration.writable_paths.is_empty());
    }

    #[test]
    fn unrestricted_local_dev_requires_approval() {
        let sandbox = SandboxDeclaration {
            profile: SandboxProfile::UnrestrictedLocalDev,
            cwd_policy: None,
            env_allowlist: None,
            network: None,
            writable_paths: None,
            require_enforcement: None,
        };

        assert_eq!(
            admit_sandbox(Some(&sandbox), &SandboxAdmissionOptions::default()),
            SandboxAdmissionDecision::ApprovalRequired {
                reasons: vec![
                    "unrestricted-local-dev sandbox requires explicit caller approval".to_owned()
                ]
            }
        );
    }

    #[test]
    fn reserved_sandbox_env_names_cover_runx_signing_and_secrets() {
        assert!(is_reserved_runx_sandbox_env_name(
            "RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64"
        ));
        assert!(is_reserved_runx_sandbox_env_name("RUNX_AGENT_API_KEY"));
        assert!(is_reserved_runx_sandbox_env_name("RUNX_GIT_ASKPASS_TOKEN"));
        assert!(is_reserved_runx_sandbox_env_name(
            "RUNX_PROVIDER_ADMISSION_SIGNING_KEY"
        ));
        assert!(!is_reserved_runx_sandbox_env_name("RUNX_CWD"));
        assert!(!is_reserved_runx_sandbox_env_name("RUNX_MCP_SCOPE"));
        assert!(!is_reserved_runx_sandbox_env_name(
            "RUNX_REGISTRY_MANIFEST_TRUST_KEY_BASE64"
        ));
        assert!(!is_reserved_runx_sandbox_env_name("PATH"));
    }

    #[test]
    fn sandbox_admission_denies_reserved_env_allowlist_names() {
        let sandbox = SandboxDeclaration {
            profile: SandboxProfile::Readonly,
            cwd_policy: None,
            env_allowlist: Some(vec![
                "PATH".to_owned(),
                "RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64".to_owned(),
            ]),
            network: None,
            writable_paths: None,
            require_enforcement: None,
        };

        assert_eq!(
            admit_sandbox(Some(&sandbox), &SandboxAdmissionOptions::default()),
            SandboxAdmissionDecision::Deny {
                reasons: vec![
                    "sandbox env_allowlist contains reserved runx environment variable(s): RUNX_RECEIPT_SIGN_ED25519_SEED_BASE64".to_owned()
                ]
            }
        );
    }
}