1const FORBIDDEN_EXTENSIONS: &[&str] = &[
7 ".so",
8 ".dylib",
9 ".dll",
10 ".exe",
11 ".dmg",
12 ".pkg",
13 ".msi",
14 ".appimage",
15 ".elf",
16 ".wasm",
17 ".bin",
18 ".sys",
19 ".ko",
20 ".kext",
21 ".app",
22 ".sh",
23 ".bash",
24 ".zsh",
25 ".fish",
26 ".py",
27 ".rb",
28 ".pl",
29 ".php",
30 ".lua",
31];
32
33const FORBIDDEN_VERSIONED_PREFIXES: &[&str] = &[".so.", ".dylib."];
36
37const INTERPRETER_DENYLIST: &[&str] = &[
39 "sh", "bash", "zsh", "dash", "fish", "python", "python3", "ruby", "perl", "php", "node",
40 "deno", "bun", "lua", "luajit", "awk", "rscript", "groovy", "kotlin", "scala", "jq",
41 "execline", "rc",
42];
43
44const CODE_EXECUTION_FLAGS: &[&str] = &[
46 "-e",
47 "--eval",
48 "-c",
49 "--command",
50 "-r",
51 "--require",
52 "-exec",
53 "--exec",
54];
55
56const SHELL_METACHARS: &[char] = &['|', ';', '&', '$', '`', '>', '<'];
58
59const PERMIT_LIST: &[&str] = &[
61 "npx",
62 "uvx",
63 "docker",
64 "podman",
65 "git",
66 "gh",
67 "npm",
68 "yarn",
69 "pnpm",
70 "curl",
71 "wget",
72 "jq",
73 "rg",
74 "fd",
75 "sd",
76 "bat",
77 "delta",
78 "ghostscript",
79 "imagemagick",
80 "ffmpeg",
81 "sqlite3",
82 "psql",
83 "mysql",
84 "redis-cli",
85];
86
87pub fn check_extension(path: &str) -> Result<(), String> {
89 if path.starts_with("assets/commander/") {
91 return Ok(());
92 }
93
94 let lower = path.to_lowercase();
95
96 for ext in FORBIDDEN_EXTENSIONS {
97 if lower.ends_with(ext) {
98 return Err(format!("forbidden file extension '{ext}' in path '{path}'"));
99 }
100 }
101
102 for prefix in FORBIDDEN_VERSIONED_PREFIXES {
103 if let Some(pos) = lower.find(prefix) {
104 let remainder = &lower[pos + prefix.len()..];
105 if !remainder.is_empty() && remainder.chars().all(|c| c.is_ascii_digit() || c == '.') {
106 return Err(format!("forbidden versioned library '{path}'"));
107 }
108 }
109 }
110
111 Ok(())
112}
113
114pub fn check_mcp_command(command: &str, args: &[String]) -> Result<(), String> {
116 if command.starts_with('/') || command.contains('/') || command.contains('\\') {
117 return Err(format!(
118 "MCP command must be basename-only, got '{command}'"
119 ));
120 }
121
122 if command.contains(SHELL_METACHARS) || command.contains(char::is_whitespace) {
123 return Err(format!("invalid characters in command '{command}'"));
124 }
125
126 let basename = command.to_lowercase();
127
128 if INTERPRETER_DENYLIST.contains(&basename.as_str()) {
129 return Err(format!(
130 "interpreter '{basename}' not allowed as MCP command"
131 ));
132 }
133
134 for arg in args {
135 for flag in CODE_EXECUTION_FLAGS {
136 if arg == *flag {
137 return Err(format!(
138 "code-execution flag '{flag}' not allowed in MCP args"
139 ));
140 }
141 }
142 }
143
144 for arg in args {
145 if arg.contains(SHELL_METACHARS) {
146 return Err(format!("shell metacharacters in arg '{arg}'"));
147 }
148 }
149
150 let combined = format!("{command} {}", args.join(" "));
151 if (combined.contains("install") || combined.contains(" add "))
152 && (args.iter().any(|a| a == "&&" || a == ";" || a == "|"))
153 {
154 return Err(format!(
155 "package-manager install chain detected: '{combined}'"
156 ));
157 }
158
159 if !PERMIT_LIST.contains(&basename.as_str()) {
160 tracing::warn!("MCP command '{command}' not in v1 permit-list");
161 }
162
163 Ok(())
164}
165
166pub fn check_mode_bits(mode: u32, is_directory: bool) -> Result<(), String> {
169 if !is_directory && (mode & 0o111) != 0 {
170 return Err(format!(
171 "regular file has execute permission bits set (mode {mode:o})"
172 ));
173 }
174 Ok(())
175}
176
177#[cfg(test)]
178mod tests {
179 use super::*;
180
181 #[test]
182 fn rejects_shared_library_extension() {
183 assert!(check_extension("lib/libevil.so").is_err());
184 assert!(check_extension("lib/libevil.SO").is_err());
185 assert!(check_extension("lib/libevil.dylib").is_err());
186 assert!(check_extension("payload.dll").is_err());
187 assert!(check_extension("tool.exe").is_err());
188 }
189
190 #[test]
191 fn rejects_versioned_shared_library() {
192 assert!(check_extension("lib/libfoo.so.1").is_err());
193 assert!(check_extension("lib/libfoo.so.0.1.0").is_err());
194 }
195
196 #[test]
197 fn rejects_wasm_and_elf() {
198 assert!(check_extension("plugin.wasm").is_err());
199 assert!(check_extension("binary.elf").is_err());
200 }
201
202 #[test]
203 fn rejects_script_extensions() {
204 assert!(check_extension("setup.sh").is_err());
205 assert!(check_extension("setup.bash").is_err());
206 assert!(check_extension("helper.py").is_err());
207 assert!(check_extension("filter.lua").is_err());
208 }
209
210 #[test]
211 fn allows_commander_assets() {
212 assert!(check_extension("assets/commander/workflows/example.js").is_ok());
213 assert!(check_extension("assets/commander/programs/research.md").is_ok());
214 }
215
216 #[test]
217 fn allows_safe_assets() {
218 assert!(check_extension("manifest.yaml").is_ok());
219 assert!(check_extension("icon/icon-512.png").is_ok());
220 assert!(check_extension("voice/voice.yaml").is_ok());
221 }
222
223 #[test]
224 fn rejects_interpreter_commands() {
225 assert!(check_mcp_command("python3", &[]).is_err());
226 assert!(check_mcp_command("bash", &[]).is_err());
227 assert!(check_mcp_command("node", &[]).is_err());
228 }
229
230 #[test]
231 fn rejects_inline_code_flags() {
232 assert!(check_mcp_command("uvx", &["-e".into(), "print 1".into()]).is_err());
233 assert!(check_mcp_command("npx", &["-e".into()]).is_err());
234 assert!(check_mcp_command("some-tool", &["--eval".into()]).is_err());
235 }
236
237 #[test]
238 fn rejects_shell_metacharacters_in_args() {
239 assert!(check_mcp_command("echo", &["hello; rm -rf /".into()]).is_err());
240 assert!(check_mcp_command("uvx", &["foo|bar".into()]).is_err());
241 }
242
243 #[test]
244 fn rejects_install_chains() {
245 let args: Vec<String> = ["install", "pkg", "&&", "rm"]
246 .iter()
247 .map(|s| s.to_string())
248 .collect();
249 assert!(check_mcp_command("uvx", &args).is_err());
250 }
251
252 #[test]
253 fn allows_safe_commands() {
254 assert!(check_mcp_command("uvx", &[]).is_ok());
255 assert!(check_mcp_command("npx", &[]).is_ok());
256 let docker_args: Vec<String> = ["run", "image"].iter().map(|s| s.to_string()).collect();
257 assert!(check_mcp_command("docker", &docker_args).is_ok());
258 let gh_args: Vec<String> = ["issue", "list"].iter().map(|s| s.to_string()).collect();
259 assert!(check_mcp_command("gh", &gh_args).is_ok());
260 }
261
262 #[test]
263 fn warns_on_unknown_command() {
264 assert!(check_mcp_command("my-custom-tool", &[]).is_ok());
265 }
266
267 #[test]
268 fn rejects_absolute_command_path() {
269 assert!(check_mcp_command("/usr/bin/npx", &[]).is_err());
270 }
271
272 #[test]
273 fn rejects_path_separator_in_command() {
274 assert!(check_mcp_command("bin/npx", &[]).is_err());
275 }
276
277 #[test]
278 fn rejects_shell_metachar_in_command() {
279 assert!(check_mcp_command("foo|bar", &[]).is_err());
280 }
281
282 #[test]
283 fn rejects_execute_bit_on_regular_file() {
284 assert!(check_mode_bits(0o755, false).is_err());
285 assert!(check_mode_bits(0o644, false).is_ok());
286 assert!(check_mode_bits(0o755, true).is_ok());
287 }
288}