use std::collections::HashMap;
pub const BLOCKED_MCP_SHELLS: &[&str] = &[
"sh",
"bash",
"dash",
"zsh",
"ksh",
"csh",
"tcsh",
"fish",
"ash",
"busybox",
"cmd",
"cmd.exe",
"powershell",
"powershell.exe",
"pwsh",
"pwsh.exe",
"python",
"python2",
"python3",
"perl",
"ruby",
"node",
"nodejs",
"deno",
"bun",
"env",
];
const FORBIDDEN_CHARS: &[char] = &[
' ', '\t', ';', '|', '&', '>', '<', '`', '$', '(', ')', '{', '}', '\n', '\r', '*', '?', '\\',
'"', '\'',
];
pub fn validate_mcp_command(command: &str) -> Result<(), String> {
if command.is_empty() {
return Err("command must not be empty".into());
}
if command.chars().any(|c| c.is_control() || c == '\u{0}') {
return Err("command contains control characters".into());
}
if command.contains(FORBIDDEN_CHARS) {
return Err(format!(
"command contains forbidden characters (shell metacharacters or whitespace): {command:?}"
));
}
if command.contains("..") {
return Err("command must not contain path traversal (..)".into());
}
let basename = command.rsplit('/').next().unwrap_or(command);
let basename_lower = basename.to_ascii_lowercase();
if BLOCKED_MCP_SHELLS.iter().any(|s| *s == basename_lower) {
return Err(format!(
"refusing to spawn shell interpreter '{basename}' as an MCP server \
(would allow arbitrary command execution)"
));
}
Ok(())
}
const BLOCKED_ENV_PREFIXES: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_",
"PYTHONPATH",
"PYTHONHOME",
"SHLIB_PATH",
"LIBPATH",
"PERL5OPT",
"PERLLIB",
"NODE_OPTIONS",
"ELECTRON_RUN_AS_NODE",
"NODE_PATH",
];
pub fn sanitize_env(env: &HashMap<String, String>) -> HashMap<String, String> {
env.iter()
.filter(|(k, _)| {
let key = k.as_str();
!BLOCKED_ENV_PREFIXES
.iter()
.any(|prefix| key.starts_with(prefix))
})
.map(|(k, v)| (k.clone(), v.clone()))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_safe_commands() {
assert!(validate_mcp_command("npx").is_ok());
assert!(validate_mcp_command("uvx").is_ok());
assert!(validate_mcp_command("/usr/local/bin/mcp-server").is_ok());
}
#[test]
fn rejects_shells() {
for shell in ["sh", "bash", "zsh", "python", "python3", "node", "env"] {
assert!(
validate_mcp_command(shell).is_err(),
"{shell} should be blocked"
);
assert!(
validate_mcp_command(&format!("/usr/bin/{shell}")).is_err(),
"/usr/bin/{shell} should be blocked"
);
}
}
#[test]
fn rejects_metacharacters_and_traversal() {
assert!(validate_mcp_command("npx; rm -rf /").is_err());
assert!(validate_mcp_command("npx && evil").is_err());
assert!(validate_mcp_command("npx $(cat /etc/passwd)").is_err());
assert!(validate_mcp_command("../escape").is_err());
assert!(validate_mcp_command("").is_err());
}
#[test]
fn sanitize_strips_loader_and_interp_paths() {
let mut env = HashMap::new();
env.insert("LD_PRELOAD".into(), "/tmp/x.so".into());
env.insert("DYLD_INSERT_LIBRARIES".into(), "/tmp/y.dylib".into());
env.insert("PYTHONPATH".into(), "/tmp".into());
env.insert("NODE_OPTIONS".into(), "--require /tmp/z".into());
env.insert("PATH".into(), "/usr/bin".into());
env.insert("HOME".into(), "/root".into());
let clean = sanitize_env(&env);
assert!(!clean.contains_key("LD_PRELOAD"));
assert!(!clean.contains_key("DYLD_INSERT_LIBRARIES"));
assert!(!clean.contains_key("PYTHONPATH"));
assert!(!clean.contains_key("NODE_OPTIONS"));
assert_eq!(clean.get("PATH").map(String::as_str), Some("/usr/bin"));
assert_eq!(clean.get("HOME").map(String::as_str), Some("/root"));
}
}