Skip to main content

mur_common/
exec.rs

1//! Shared executable-path resolution.
2//!
3//! A single source of truth for turning a command (bare program name or path)
4//! into the absolute, symlink-resolved binary that will actually be executed.
5//! Used by both install-time MCP pinning (`mur agent mcp pin`) and the runtime
6//! startup verification (B0 rules 6 & 11) so a bare `command` like `node`
7//! resolves identically across the two passes — otherwise the runtime hashes a
8//! CWD-relative path that doesn't exist and silently skips the pin/signature
9//! check while `Command::new` runs the PATH-resolved binary.
10
11use anyhow::{Context, Result, bail};
12use sha2::{Digest, Sha256};
13use std::path::{Path, PathBuf};
14
15/// File name of the MUR MCP server binary.
16#[cfg(windows)]
17const MCP_SERVER_BIN: &str = "mur-mcp-server.exe";
18#[cfg(not(windows))]
19const MCP_SERVER_BIN: &str = "mur-mcp-server";
20
21/// Canonical location MUR keeps its own copy of the MCP server binary:
22/// `~/.mur/mcp-servers/mur-mcp-server` (honors `$MUR_HOME`). Stable across how
23/// `mur` itself was installed (brew / cargo / source) and across upgrades, so
24/// agent profiles can pin this path once and never go stale.
25pub fn bundled_mcp_server_path() -> PathBuf {
26    crate::trust::mur_home()
27        .join("mcp-servers")
28        .join(MCP_SERVER_BIN)
29}
30
31/// Ensure [`bundled_mcp_server_path`] exists and matches the `mur-mcp-server`
32/// shipped alongside the running `mur` binary, copying it into place when
33/// missing or out of date. Returns the canonical target path.
34///
35/// Source resolution: the sibling of the current executable first (brew, cargo
36/// and source builds all colocate the two binaries), then `mur-mcp-server` on
37/// `PATH`. If no source is found but a copy already exists, that copy is
38/// returned (usable, just can't self-update). Errors only when there is neither
39/// a source nor an existing copy.
40///
41/// Call this BEFORE the kernel sandbox seals — the copy needs write access to
42/// `~/.mur`.
43pub fn ensure_bundled_mcp_server() -> Result<PathBuf> {
44    let target = bundled_mcp_server_path();
45    match locate_mcp_server_source() {
46        Some(src) => {
47            install_if_stale(&src, &target)?;
48            Ok(target)
49        }
50        None if target.is_file() => Ok(target),
51        None => bail!(
52            "mur-mcp-server not found next to `mur` or on PATH, and no copy at {}",
53            target.display()
54        ),
55    }
56}
57
58/// The `mur-mcp-server` to copy from: sibling of `mur` first, then PATH.
59fn locate_mcp_server_source() -> Option<PathBuf> {
60    if let Ok(exe) = std::env::current_exe()
61        && let Some(dir) = exe.parent()
62    {
63        let sibling = dir.join(MCP_SERVER_BIN);
64        if sibling.is_file() {
65            return sibling.canonicalize().ok();
66        }
67    }
68    resolve_command(MCP_SERVER_BIN).ok()
69}
70
71/// Copy `src` to `target` unless `target` already byte-matches it. Idempotent;
72/// writes via a uniquely-named temp file + rename in the target dir so the swap
73/// is atomic and never leaves a half-written binary an agent might try to spawn;
74/// sets mode 0755 on unix.
75fn install_if_stale(src: &Path, target: &Path) -> Result<()> {
76    if target.is_file() && sha256_file(src)? == sha256_file(target)? {
77        return Ok(());
78    }
79    let dir = target
80        .parent()
81        .ok_or_else(|| anyhow::anyhow!("target {} has no parent", target.display()))?;
82    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
83    // Unique temp name so two agents starting at once don't clobber each other.
84    let tmp = dir.join(format!(".{MCP_SERVER_BIN}.{}.tmp", std::process::id()));
85    std::fs::copy(src, &tmp)
86        .with_context(|| format!("copy {} -> {}", src.display(), tmp.display()))?;
87    #[cfg(unix)]
88    {
89        use std::os::unix::fs::PermissionsExt;
90        std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
91            .with_context(|| format!("chmod {}", tmp.display()))?;
92    }
93    std::fs::rename(&tmp, target)
94        .with_context(|| format!("rename {} -> {}", tmp.display(), target.display()))?;
95    Ok(())
96}
97
98/// Stream-hash `path` SHA-256 (64 KiB chunks; lowercase hex).
99fn sha256_file(path: &Path) -> Result<String> {
100    use std::io::Read;
101    let mut f = std::fs::File::open(path).with_context(|| format!("open {}", path.display()))?;
102    let mut hasher = Sha256::new();
103    let mut buf = [0u8; 65536];
104    loop {
105        let n = f
106            .read(&mut buf)
107            .with_context(|| format!("read {}", path.display()))?;
108        if n == 0 {
109            break;
110        }
111        hasher.update(&buf[..n]);
112    }
113    Ok(hex::encode(hasher.finalize()))
114}
115
116/// Launchers that run *other* code: hashing one of these tells you nothing
117/// about the MCP server it starts.
118const INTERPRETERS: &[&str] = &[
119    "npx", "node", "bunx", "bun", "deno", "python", "python3", "uv", "uvx", "pipx", "ruby", "perl",
120    "sh", "bash", "zsh",
121];
122
123/// Whether `command` launches an MCP server through an interpreter or package
124/// runner rather than being the server binary itself.
125///
126/// This decides whether a `binary_sha256` pin means anything. For
127/// `command: npx, args: @yawlabs/fetch-mcp` the pin hashes **npx** — so it
128/// breaks on every unrelated Node upgrade while saying nothing at all about
129/// `@yawlabs/fetch-mcp`, which npx resolves and may fetch fresh at run time.
130/// Enforcing such a pin is both fragile and hollow; the honest report is that
131/// the server code is unprotected.
132///
133/// Real coverage for these needs a package-level pin (version + integrity),
134/// which is a different mechanism than hashing a file on disk.
135pub fn is_interpreter_command(command: &str) -> bool {
136    let first = command.split_whitespace().next().unwrap_or(command);
137    let stem = Path::new(first)
138        .file_stem() // also strips .exe / .cmd on Windows
139        .and_then(|s| s.to_str())
140        .unwrap_or(first);
141    INTERPRETERS.contains(&stem.to_ascii_lowercase().as_str())
142}
143
144/// Resolve `command` to an absolute path on disk.
145///
146/// - If `command` is already absolute or contains a path separator, canonicalize
147///   it (resolves symlinks).
148/// - Otherwise consult `PATH` (and try a `.exe` suffix on Windows). Returns the
149///   first match found, canonicalized.
150///
151/// Returns an error if the binary can't be located.
152pub fn resolve_command(command: &str) -> Result<PathBuf> {
153    let p = Path::new(command);
154    if p.is_absolute() || command.contains('/') || command.contains('\\') {
155        return p
156            .canonicalize()
157            .with_context(|| format!("canonicalize {command}"));
158    }
159    let path_var = std::env::var_os("PATH")
160        .ok_or_else(|| anyhow::anyhow!("PATH env var unset; cannot resolve `{command}`"))?;
161    for dir in std::env::split_paths(&path_var) {
162        let candidate = dir.join(command);
163        if candidate.is_file() {
164            return candidate
165                .canonicalize()
166                .with_context(|| format!("canonicalize {}", candidate.display()));
167        }
168        #[cfg(target_os = "windows")]
169        {
170            let with_exe = dir.join(format!("{command}.exe"));
171            if with_exe.is_file() {
172                return with_exe
173                    .canonicalize()
174                    .with_context(|| format!("canonicalize {}", with_exe.display()));
175            }
176        }
177    }
178    bail!("could not find `{command}` on PATH");
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184
185    #[test]
186    fn interpreter_commands_are_recognised_including_paths_and_args() {
187        for c in [
188            "npx",
189            "node",
190            "python3",
191            "uvx",
192            "bunx",
193            "deno",
194            "sh",
195            "/opt/homebrew/bin/npx",
196            "npx @yawlabs/fetch-mcp",
197            "NPX",
198            "npx.cmd",
199        ] {
200            assert!(
201                is_interpreter_command(c),
202                "`{c}` should count as an interpreter"
203            );
204        }
205    }
206
207    #[test]
208    fn real_server_binaries_are_not_interpreters() {
209        for c in [
210            "mur-mcp-server",
211            "/Users/x/.mur/mcp-servers/mur-mcp-server",
212            "agent-browser",
213            "mur-research-gateway",
214            "nodemon-ish",
215        ] {
216            assert!(!is_interpreter_command(c), "`{c}` is the server itself");
217        }
218    }
219
220    #[test]
221    fn errors_on_missing_binary() {
222        assert!(resolve_command("definitely-not-a-real-binary-xyz123").is_err());
223    }
224
225    #[cfg(unix)]
226    #[test]
227    fn resolves_bare_program_on_path_to_absolute() {
228        // The whole point: a bare program name resolves to an absolute path.
229        // (The runtime pin check used to open it relative to CWD and soft-fail.)
230        let resolved = resolve_command("sh").expect("sh is on PATH");
231        assert!(
232            resolved.is_absolute(),
233            "expected absolute, got {resolved:?}"
234        );
235        assert!(resolved.exists());
236    }
237
238    #[test]
239    fn absolute_path_is_canonicalized() {
240        let tmp = tempfile::NamedTempFile::new().unwrap();
241        let resolved = resolve_command(tmp.path().to_str().unwrap()).unwrap();
242        assert!(resolved.is_absolute());
243    }
244
245    #[test]
246    fn install_if_stale_copies_then_is_idempotent_and_updates() {
247        let dir = tempfile::tempdir().unwrap();
248        let src = dir.path().join("src-bin");
249        let target = dir.path().join("mcp-servers/mur-mcp-server"); // parent must be created
250        std::fs::write(&src, b"v1").unwrap();
251
252        // Missing target -> copied.
253        install_if_stale(&src, &target).unwrap();
254        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
255        #[cfg(unix)]
256        {
257            use std::os::unix::fs::PermissionsExt;
258            let mode = std::fs::metadata(&target).unwrap().permissions().mode();
259            assert_eq!(mode & 0o111, 0o111, "target must be executable");
260        }
261
262        // Unchanged source -> no-op, still v1.
263        install_if_stale(&src, &target).unwrap();
264        assert_eq!(std::fs::read(&target).unwrap(), b"v1");
265
266        // Updated source -> refreshed.
267        std::fs::write(&src, b"v2-newer").unwrap();
268        install_if_stale(&src, &target).unwrap();
269        assert_eq!(std::fs::read(&target).unwrap(), b"v2-newer");
270    }
271}