aidens-tool-kit 0.1.0

Tool registry, exposure planning, and safe sandbox-validated dispatch
Documentation
use aidens_security_kit::{validate_sandbox_path, PathSafetyError};
use anyhow::{anyhow, bail, Context};
use serde_json::Value;
use std::path::{Component, Path, PathBuf};

pub(crate) fn canonical_sandbox_root(path: &Path) -> anyhow::Result<PathBuf> {
    let canonical = path
        .canonicalize()
        .with_context(|| format!("sandbox root does not exist: {}", path.display()))?;
    if !canonical.is_dir() {
        bail!("sandbox root is not a directory: {}", canonical.display())
    }
    Ok(canonical)
}

pub(crate) fn resolve_existing_sandboxed_path(
    sandbox_root: &Path,
    requested: &str,
) -> anyhow::Result<PathBuf> {
    let requested_path = Path::new(requested);
    if requested_path.is_absolute() {
        bail!("absolute path escape rejected by sandbox")
    }
    if requested_path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    }) {
        bail!("path traversal rejected by sandbox: {requested}")
    }

    let joined = sandbox_root.join(requested_path);
    let canonical = joined.canonicalize().with_context(|| {
        format!("repo-read path cannot be resolved inside sandbox: {requested}")
    })?;
    validate_sandbox_path(&canonical, sandbox_root)
        .map_err(|error| anyhow!(path_safety_message(error, requested)))?;
    Ok(canonical)
}

pub(crate) fn resolve_target_sandboxed_path(
    sandbox_root: &Path,
    requested: &str,
) -> anyhow::Result<PathBuf> {
    let requested_path = Path::new(requested);
    if requested_path.is_absolute() {
        bail!("absolute path escape rejected by sandbox")
    }
    if requested_path.components().any(|component| {
        matches!(
            component,
            Component::ParentDir | Component::RootDir | Component::Prefix(_)
        )
    }) {
        bail!("path traversal rejected by sandbox: {requested}")
    }
    let joined = sandbox_root.join(requested_path);
    let parent = joined.parent().unwrap_or(sandbox_root);
    let canonical_parent = parent.canonicalize().with_context(|| {
        format!("patch target parent cannot be resolved inside sandbox: {requested}")
    })?;
    validate_sandbox_path(&canonical_parent, sandbox_root)
        .map_err(|error| anyhow!(path_safety_message(error, requested)))?;
    let file_name = joined
        .file_name()
        .ok_or_else(|| anyhow!("patch target must name a file: {requested}"))?;
    if let Ok(metadata) = std::fs::symlink_metadata(&joined) {
        if metadata.file_type().is_symlink() {
            bail!("symlink write target rejected by sandbox: {requested}");
        }
        reject_hardlinked_file(&joined, &metadata)?;
        let canonical_target = joined.canonicalize().with_context(|| {
            format!("patch target cannot be resolved inside sandbox: {requested}")
        })?;
        validate_sandbox_path(&canonical_target, sandbox_root)
            .map_err(|error| anyhow!(path_safety_message(error, requested)))?;
    }
    Ok(canonical_parent.join(file_name))
}

pub(crate) fn path_safety_message(error: PathSafetyError, requested: &str) -> String {
    match error {
        PathSafetyError::TraversalNotAllowed => {
            format!("path traversal rejected by sandbox: {requested}")
        }
        PathSafetyError::OutsideSandbox { root } => {
            let _ = root;
            format!("path escape rejected by sandbox: {requested}; outside declared sandbox root")
        }
        PathSafetyError::SensitivePrefix { prefix } => {
            format!("sensitive prefix rejected by sandbox: {requested}; prefix {prefix}")
        }
        PathSafetyError::HiddenOrSensitiveComponent { component } => {
            format!("hidden or sensitive component rejected by sandbox: {requested}; component {component}")
        }
    }
}

#[cfg(unix)]
pub(crate) fn reject_hardlinked_file(
    path: &Path,
    metadata: &std::fs::Metadata,
) -> anyhow::Result<()> {
    use std::os::unix::fs::MetadataExt;

    if metadata.is_file() && metadata.nlink() > 1 {
        bail!(
            "hardlink read target rejected by sandbox: {}",
            display_redacted_path(path)
        );
    }
    Ok(())
}

#[cfg(not(unix))]
pub(crate) fn reject_hardlinked_file(
    _path: &Path,
    _metadata: &std::fs::Metadata,
) -> anyhow::Result<()> {
    Ok(())
}

pub(crate) fn display_redacted_path(path: &Path) -> String {
    path.file_name()
        .and_then(|name| name.to_str())
        .map(|name| format!("<sandbox>/{name}"))
        .unwrap_or_else(|| "<sandbox>/<unknown>".into())
}

pub(crate) fn path_is_denied_by_prefix(sandbox_root: &Path, path: &Path) -> bool {
    validate_sandbox_path(path, sandbox_root).is_err()
}

pub(crate) fn display_sandbox_path(sandbox_root: &Path, path: &Path) -> String {
    path.strip_prefix(sandbox_root)
        .map(|relative| relative.display().to_string().replace('\\', "/"))
        .unwrap_or_else(|_| display_redacted_path(path))
}

pub(crate) fn collect_search_matches(
    sandbox_root: &Path,
    current: &Path,
    query: &str,
    max_matches: usize,
    matches: &mut Vec<Value>,
) -> anyhow::Result<()> {
    if matches.len() >= max_matches || path_is_denied_by_prefix(sandbox_root, current) {
        return Ok(());
    }
    let metadata = std::fs::symlink_metadata(current)?;
    if metadata.file_type().is_symlink() {
        return Ok(());
    }
    if metadata.is_dir() {
        for entry in std::fs::read_dir(current)? {
            let entry = entry?;
            let path = entry.path();
            let display = display_sandbox_path(sandbox_root, &path);
            if path_has_denied_component(Path::new(&display)) {
                continue;
            }
            collect_search_matches(sandbox_root, &path, query, max_matches, matches)?;
            if matches.len() >= max_matches {
                break;
            }
        }
    } else if metadata.is_file() && metadata.len() <= 1_048_576 {
        let Ok(content) = std::fs::read_to_string(current) else {
            return Ok(());
        };
        for (line_index, line) in content.lines().enumerate() {
            if line.contains(query) {
                matches.push(serde_json::json!({
                    "path": display_sandbox_path(sandbox_root, current),
                    "line": line_index + 1,
                    "text": line,
                }));
                if matches.len() >= max_matches {
                    break;
                }
            }
        }
    }
    Ok(())
}

pub(crate) fn path_has_denied_component(path: &Path) -> bool {
    path.components().any(|component| {
        matches!(
            component,
            Component::Normal(name) if name == ".git" || name == "target"
        )
    })
}