runx-runtime 0.6.19

Native Rust runtime for local runx execution, adapters, harness replay, receipts, and sandboxing.
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

use runx_contracts::JsonObject;
use runx_core::policy::{CwdPolicy, SandboxProfile};
use runx_parser::{SkillSandbox, SkillSource};

use crate::RuntimeError;
use crate::receipts::paths::{INIT_CWD_ENV, RUNX_CWD_ENV};

use super::template::{has_unresolved_template, resolve_template};

pub(super) fn resolve_cwd(
    source: &SkillSource,
    sandbox: Option<&SkillSandbox>,
    skill_directory: &Path,
    workspace_cwd: Option<&Path>,
) -> Result<PathBuf, RuntimeError> {
    resolve_cwd_value(
        source.cwd.as_deref(),
        sandbox,
        skill_directory,
        workspace_cwd,
    )
}

pub(super) fn resolve_cwd_value(
    source_cwd: Option<&str>,
    sandbox: Option<&SkillSandbox>,
    skill_directory: &Path,
    workspace_cwd: Option<&Path>,
) -> Result<PathBuf, RuntimeError> {
    let policy = sandbox
        .and_then(|sandbox| sandbox.cwd_policy.as_ref().map(CwdPolicy::as_str))
        .unwrap_or("skill-directory");
    let profile = sandbox
        .map(|sandbox| sandbox.profile.as_str())
        .unwrap_or("readonly");
    let cwd = match (policy, source_cwd) {
        ("custom", Some(cwd)) => Ok(resolve_path(skill_directory, cwd)),
        ("workspace", Some(cwd)) => Ok(resolve_path(skill_directory, cwd)),
        ("workspace", None) => workspace_cwd.map(Path::to_path_buf).map_or_else(
            || {
                std::env::current_dir()
                    .map_err(|source| RuntimeError::io("resolving workspace cwd", source))
            },
            Ok,
        ),
        (_, Some(cwd)) => Ok(resolve_path(skill_directory, cwd)),
        _ => Ok(skill_directory.to_path_buf()),
    }?;
    validate_cwd_policy(policy, profile, &cwd, skill_directory, workspace_cwd)?;
    Ok(normalize_path(&cwd))
}

pub(super) fn workspace_cwd(
    env: &BTreeMap<String, String>,
) -> Result<Option<PathBuf>, RuntimeError> {
    let Some(path) = env.get(RUNX_CWD_ENV).or_else(|| env.get(INIT_CWD_ENV)) else {
        return Ok(None);
    };
    let path = PathBuf::from(path);
    if path.is_absolute() {
        Ok(Some(path))
    } else {
        std::env::current_dir()
            .map(|cwd| Some(cwd.join(path)))
            .map_err(|source| RuntimeError::io("resolving relative workspace cwd", source))
    }
}

pub(super) fn resolve_path(base: &Path, path: &str) -> PathBuf {
    let candidate = PathBuf::from(path);
    if candidate.is_absolute() {
        candidate
    } else {
        base.join(candidate)
    }
}

fn validate_cwd_policy(
    policy: &str,
    profile: &str,
    cwd: &Path,
    skill_directory: &Path,
    workspace_cwd: Option<&Path>,
) -> Result<(), RuntimeError> {
    if profile == "unrestricted-local-dev" {
        return Ok(());
    }
    let cwd = containment_path(cwd, "resolving sandbox cwd")?;
    let skill_directory = containment_path(skill_directory, "resolving sandbox skill directory")?;
    let workspace_root = match workspace_cwd {
        Some(workspace_cwd) => containment_path(workspace_cwd, "resolving sandbox workspace")?,
        None => containment_path(
            &std::env::current_dir().map_err(|source| {
                RuntimeError::io("resolving workspace cwd for sandbox policy", source)
            })?,
            "resolving sandbox workspace",
        )?,
    };
    match policy {
        "unrestricted-local-dev" => Ok(()),
        "custom"
            if is_within_path(&cwd, &skill_directory) || is_within_path(&cwd, &workspace_root) =>
        {
            Ok(())
        }
        "custom" => Err(sandbox_violation(format!(
            "sandbox custom cwd '{}' is outside skill directory '{}' and workspace '{}'",
            cwd.display(),
            skill_directory.display(),
            workspace_root.display()
        ))),
        "skill-directory" if is_within_path(&cwd, &skill_directory) => Ok(()),
        "skill-directory" => Err(sandbox_violation(format!(
            "sandbox cwd '{}' is outside skill directory '{}'",
            cwd.display(),
            skill_directory.display()
        ))),
        "workspace" if is_within_path(&cwd, &workspace_root) => Ok(()),
        "workspace" => Err(sandbox_violation(format!(
            "sandbox cwd '{}' is outside workspace '{}'",
            cwd.display(),
            workspace_root.display()
        ))),
        _ => Ok(()),
    }
}

fn is_within_path(candidate: &Path, root: &Path) -> bool {
    candidate == root || candidate.starts_with(root)
}

pub(super) fn normalize_path(path: &Path) -> PathBuf {
    crate::path_util::lexical_normalize(path)
}

pub(super) fn validate_sandbox(sandbox: Option<&SkillSandbox>) -> Result<(), RuntimeError> {
    let Some(sandbox) = sandbox else {
        return Ok(());
    };
    match sandbox.profile.as_str() {
        "readonly" => validate_readonly_sandbox(sandbox),
        "workspace-write" | "network" => Ok(()),
        "unrestricted-local-dev" => validate_unrestricted_sandbox(sandbox),
        profile => Err(sandbox_violation(format!(
            "unsupported sandbox profile '{profile}'"
        ))),
    }
}

pub(super) fn resolved_writable_paths(
    sandbox: Option<&SkillSandbox>,
    inputs: &JsonObject,
    base_env: &BTreeMap<String, String>,
) -> Vec<String> {
    sandbox.map_or_else(Vec::new, |sandbox| {
        sandbox
            .writable_paths
            .iter()
            .map(|path| resolve_template(path, inputs, base_env))
            .filter(|path| !path.trim().is_empty() && !has_unresolved_template(path))
            .collect()
    })
}

pub(super) fn validated_writable_paths(
    sandbox: Option<&SkillSandbox>,
    writable_paths: &[String],
    cwd: &Path,
    workspace_cwd: Option<&Path>,
) -> Result<Vec<PathBuf>, RuntimeError> {
    let Some(sandbox) = sandbox else {
        return Ok(Vec::new());
    };
    if sandbox.profile != SandboxProfile::WorkspaceWrite {
        return Ok(Vec::new());
    }
    let workspace_root = match workspace_cwd {
        Some(workspace_cwd) => {
            containment_path(workspace_cwd, "resolving sandbox writable workspace")?
        }
        None => containment_path(
            &std::env::current_dir().map_err(|source| {
                RuntimeError::io("resolving workspace cwd for sandbox writable paths", source)
            })?,
            "resolving sandbox writable workspace",
        )?,
    };
    let resolved = writable_paths
        .iter()
        .map(|path| validate_writable_path_literal(path).map(|()| path))
        .collect::<Result<Vec<_>, _>>()?
        .into_iter()
        .map(|path| containment_path(&resolve_path(cwd, path), "resolving sandbox writable path"))
        .collect::<Result<Vec<_>, _>>()?;
    let escaped = resolved
        .iter()
        .filter(|path| !is_within_path(path, &workspace_root))
        .cloned()
        .collect::<Vec<_>>();
    if !escaped.is_empty() {
        return Err(sandbox_violation(format!(
            "workspace-write sandbox has writable path(s) outside workspace: {}",
            escaped
                .iter()
                .map(|path| path.display().to_string())
                .collect::<Vec<_>>()
                .join(", ")
        )));
    }
    Ok(resolved)
}

fn validate_writable_path_literal(path: &str) -> Result<(), RuntimeError> {
    if path.chars().any(|character| {
        character.is_control() || matches!(character, '(' | ')' | '"' | '\\' | ';')
    }) {
        return Err(sandbox_violation(
            "workspace-write sandbox writable path contains unsupported profile metacharacters",
        ));
    }
    Ok(())
}

fn containment_path(path: &Path, operation: &'static str) -> Result<PathBuf, RuntimeError> {
    if path.exists() {
        return fs::canonicalize(path).map_err(|source| RuntimeError::io(operation, source));
    }
    let normalized = normalize_path(path);
    let mut ancestor = normalized.as_path();
    let mut missing_tail = Vec::new();

    loop {
        if ancestor.exists() {
            let mut resolved =
                fs::canonicalize(ancestor).map_err(|source| RuntimeError::io(operation, source))?;
            for component in missing_tail.iter().rev() {
                resolved.push(component);
            }
            return Ok(resolved);
        }

        let Some(file_name) = ancestor.file_name() else {
            return Ok(normalized);
        };
        missing_tail.push(PathBuf::from(file_name));

        let Some(parent) = ancestor
            .parent()
            .filter(|parent| !parent.as_os_str().is_empty())
        else {
            return Ok(normalized);
        };
        ancestor = parent;
    }
}

fn validate_readonly_sandbox(sandbox: &SkillSandbox) -> Result<(), RuntimeError> {
    if sandbox.network == Some(true) {
        return Err(sandbox_violation("readonly sandbox cannot request network"));
    }
    if !sandbox.writable_paths.is_empty() {
        return Err(sandbox_violation(
            "readonly sandbox cannot declare writable paths",
        ));
    }
    Ok(())
}

fn validate_unrestricted_sandbox(sandbox: &SkillSandbox) -> Result<(), RuntimeError> {
    if sandbox.approved_escalation == Some(true) {
        Ok(())
    } else {
        Err(sandbox_violation(
            "unrestricted-local-dev requires approved escalation",
        ))
    }
}

pub(super) fn sandbox_violation(message: impl Into<String>) -> RuntimeError {
    RuntimeError::SandboxViolation {
        message: message.into(),
    }
}

#[cfg(test)]
mod tests {
    use std::path::{Path, PathBuf};

    use super::normalize_path;

    #[test]
    fn normalize_path_preserves_current_directory() {
        assert_eq!(normalize_path(Path::new(".")), PathBuf::from("."));
        assert_eq!(normalize_path(Path::new("skill/..")), PathBuf::from("."));
    }
}