Skip to main content

mur_common/muragent/
executable_ban.rs

1//! MCP command validation — deny-list, permit-list, metacharacter scan.
2//!
3//! Spec §6.4 step 2: reject executable content in `.muragent` packages.
4
5/// File extensions that are forbidden inside a `.muragent` (case-insensitive).
6const 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
33/// Additional forbidden extensions that match versioned shared libraries
34/// (e.g., `.so.1`, `.so.0.1.0`).
35const FORBIDDEN_VERSIONED_PREFIXES: &[&str] = &[".so.", ".dylib."];
36
37/// Shell interpreters forbidden as MCP command basenames.
38const 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
44/// Inline-code-execution flags that must not appear in MCP args.
45const CODE_EXECUTION_FLAGS: &[&str] = &[
46    "-e",
47    "--eval",
48    "-c",
49    "--command",
50    "-r",
51    "--require",
52    "-exec",
53    "--exec",
54];
55
56/// Shell metacharacters forbidden in MCP commands and args.
57const SHELL_METACHARS: &[char] = &['|', ';', '&', '$', '`', '>', '<'];
58
59/// Permit-list for MCP command basenames.
60const 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
87/// Validate a file path inside the tarball for executable content.
88pub fn check_extension(path: &str) -> Result<(), String> {
89    // Assets inside Commander's data namespace may contain .js/.ts as data.
90    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
114/// Validate an MCP server command string.
115pub 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
166/// Check tar entry mode bits: regular files with execute bit are rejected.
167/// Directories with execute bit are fine.
168pub 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}