1use std::collections::HashMap;
16
17pub const BLOCKED_MCP_SHELLS: &[&str] = &[
20 "sh",
21 "bash",
22 "dash",
23 "zsh",
24 "ksh",
25 "csh",
26 "tcsh",
27 "fish",
28 "ash",
29 "busybox",
30 "cmd",
33 "cmd.exe",
34 "powershell",
35 "powershell.exe",
36 "pwsh",
37 "pwsh.exe",
38 "python",
40 "python2",
41 "python3",
42 "perl",
43 "ruby",
44 "node",
45 "nodejs",
46 "deno",
47 "bun",
48 "env",
49];
50
51const FORBIDDEN_CHARS: &[char] = &[
55 ' ', '\t', ';', '|', '&', '>', '<', '`', '$', '(', ')', '{', '}', '\n', '\r', '*', '?', '\\',
56 '"', '\'',
57];
58
59pub fn validate_mcp_command(command: &str) -> Result<(), String> {
69 if command.is_empty() {
70 return Err("command must not be empty".into());
71 }
72 if command.chars().any(|c| c.is_control() || c == '\u{0}') {
74 return Err("command contains control characters".into());
75 }
76 if command.contains(FORBIDDEN_CHARS) {
80 return Err(format!(
81 "command contains forbidden characters (shell metacharacters or whitespace): {command:?}"
82 ));
83 }
84 if command.contains("..") {
86 return Err("command must not contain path traversal (..)".into());
87 }
88 let basename = command.rsplit('/').next().unwrap_or(command);
90 let basename_lower = basename.to_ascii_lowercase();
91 if BLOCKED_MCP_SHELLS.iter().any(|s| *s == basename_lower) {
92 return Err(format!(
93 "refusing to spawn shell interpreter '{basename}' as an MCP server \
94 (would allow arbitrary command execution)"
95 ));
96 }
97 Ok(())
98}
99
100const BLOCKED_ENV_PREFIXES: &[&str] = &[
104 "LD_PRELOAD",
105 "LD_LIBRARY_PATH",
106 "DYLD_",
107 "PYTHONPATH",
108 "PYTHONHOME",
109 "SHLIB_PATH",
110 "LIBPATH",
111 "PERL5OPT",
112 "PERLLIB",
113 "NODE_OPTIONS",
114 "ELECTRON_RUN_AS_NODE",
115 "NODE_PATH",
116];
117
118pub fn sanitize_env(env: &HashMap<String, String>) -> HashMap<String, String> {
128 env.iter()
129 .filter(|(k, _)| {
130 let key = k.as_str();
131 !BLOCKED_ENV_PREFIXES
132 .iter()
133 .any(|prefix| key.starts_with(prefix))
134 })
135 .map(|(k, v)| (k.clone(), v.clone()))
136 .collect()
137}
138
139#[cfg(test)]
140mod tests {
141 use super::*;
142
143 #[test]
144 fn accepts_safe_commands() {
145 assert!(validate_mcp_command("npx").is_ok());
146 assert!(validate_mcp_command("uvx").is_ok());
147 assert!(validate_mcp_command("/usr/local/bin/mcp-server").is_ok());
148 }
149
150 #[test]
151 fn rejects_shells() {
152 for shell in ["sh", "bash", "zsh", "python", "python3", "node", "env"] {
153 assert!(
154 validate_mcp_command(shell).is_err(),
155 "{shell} should be blocked"
156 );
157 assert!(
159 validate_mcp_command(&format!("/usr/bin/{shell}")).is_err(),
160 "/usr/bin/{shell} should be blocked"
161 );
162 }
163 }
164
165 #[test]
166 fn rejects_metacharacters_and_traversal() {
167 assert!(validate_mcp_command("npx; rm -rf /").is_err());
168 assert!(validate_mcp_command("npx && evil").is_err());
169 assert!(validate_mcp_command("npx $(cat /etc/passwd)").is_err());
170 assert!(validate_mcp_command("../escape").is_err());
171 assert!(validate_mcp_command("").is_err());
172 }
173
174 #[test]
175 fn sanitize_strips_loader_and_interp_paths() {
176 let mut env = HashMap::new();
177 env.insert("LD_PRELOAD".into(), "/tmp/x.so".into());
178 env.insert("DYLD_INSERT_LIBRARIES".into(), "/tmp/y.dylib".into());
179 env.insert("PYTHONPATH".into(), "/tmp".into());
180 env.insert("NODE_OPTIONS".into(), "--require /tmp/z".into());
181 env.insert("PATH".into(), "/usr/bin".into());
182 env.insert("HOME".into(), "/root".into());
183
184 let clean = sanitize_env(&env);
185 assert!(!clean.contains_key("LD_PRELOAD"));
186 assert!(!clean.contains_key("DYLD_INSERT_LIBRARIES"));
187 assert!(!clean.contains_key("PYTHONPATH"));
188 assert!(!clean.contains_key("NODE_OPTIONS"));
189 assert_eq!(clean.get("PATH").map(String::as_str), Some("/usr/bin"));
191 assert_eq!(clean.get("HOME").map(String::as_str), Some("/root"));
192 }
193}