bctx-nexus 0.1.14

bctx-nexus — MCP/Nexus gateway with permission enforcement and tool registry
Documentation
use anyhow::{bail, Result};

static SHELL_INJECTION: &[&str] = &["$(", "`", "&&", "||", ";", "|", ">", "<", "\n", "\0"];

/// Reject strings containing shell injection patterns.
/// Use for skill input fields that will be interpolated into shell commands.
pub fn sanitize_tool_input(raw: &str) -> Result<String> {
    for pat in SHELL_INJECTION {
        if raw.contains(pat) {
            bail!("input contains disallowed pattern: {:?}", pat);
        }
    }
    Ok(raw.to_string())
}

/// Normalize and jail a filesystem path within `root`.
///
/// Resolves `..` and `.` components lexically (no I/O), then rejects paths
/// that escape the jail root. Returns the normalized absolute path on success.
///
/// # Security note
/// The old `Path::starts_with` check on raw paths was bypassable via
/// `/root/foo/../../../etc/passwd` — this function fixes that by normalizing
/// before comparison.
pub fn jail_path(path: &str, root: &str) -> Result<String> {
    let norm_path = normalize(std::path::Path::new(path));
    let norm_root = normalize(std::path::Path::new(root));
    if norm_path.starts_with(&norm_root) {
        Ok(norm_path.to_string_lossy().into_owned())
    } else {
        bail!("path escapes jail: {path}")
    }
}

/// Resolve `..` and `.` components lexically without touching the filesystem.
fn normalize(path: &std::path::Path) -> std::path::PathBuf {
    let mut out = std::path::PathBuf::new();
    for component in path.components() {
        match component {
            std::path::Component::ParentDir => {
                out.pop();
            }
            std::path::Component::CurDir => {}
            c => out.push(c),
        }
    }
    out
}

#[cfg(test)]
mod unit {
    use super::*;

    #[test]
    fn sanitize_blocks_subshell_dollar() {
        assert!(sanitize_tool_input("foo $(id)").is_err());
    }
    #[test]
    fn sanitize_blocks_subshell_backtick() {
        assert!(sanitize_tool_input("foo `id`").is_err());
    }
    #[test]
    fn sanitize_blocks_pipe() {
        assert!(sanitize_tool_input("foo | cat").is_err());
    }
    #[test]
    fn sanitize_blocks_semicolon() {
        assert!(sanitize_tool_input("a; b").is_err());
    }
    #[test]
    fn sanitize_blocks_newline() {
        assert!(sanitize_tool_input("a\nb").is_err());
    }
    #[test]
    fn sanitize_blocks_null_byte() {
        assert!(sanitize_tool_input("a\0b").is_err());
    }
    #[test]
    fn sanitize_allows_clean_input() {
        assert!(sanitize_tool_input("hello world").is_ok());
    }
    #[test]
    fn sanitize_allows_file_paths() {
        assert!(sanitize_tool_input("/home/user/file.txt").is_ok());
    }

    #[test]
    fn jail_allows_direct_child() {
        assert!(jail_path("/project/main.rs", "/project").is_ok());
    }

    #[test]
    fn jail_blocks_dotdot_traversal() {
        // /project/foo/../../../etc/passwd normalises to /etc/passwd — must be rejected.
        assert!(jail_path("/project/foo/../../../etc/passwd", "/project").is_err());
    }

    #[test]
    fn jail_blocks_sibling_dir() {
        assert!(jail_path("/other/path", "/project").is_err());
    }

    #[test]
    fn jail_normalises_internal_dotdot() {
        // /project/src/../lib/mod.rs → /project/lib/mod.rs — still inside jail.
        let r = jail_path("/project/src/../lib/mod.rs", "/project").unwrap();
        assert!(r.ends_with("lib/mod.rs"), "{r}");
    }

    #[test]
    fn jail_normalises_curdir() {
        let r = jail_path("/project/./src/main.rs", "/project").unwrap();
        assert!(r.ends_with("src/main.rs"), "{r}");
    }

    #[test]
    fn normalize_resolves_double_dotdot() {
        let p = normalize(std::path::Path::new("/a/b/c/../../d"));
        assert_eq!(p, std::path::PathBuf::from("/a/d"));
    }
}