Skip to main content

shell_mcp/
root.rs

1//! Resolution of the launch root.
2//!
3//! The launch root is the directory shell-mcp pins every executed command
4//! into. v0.1.0 derived it from the process working directory, which broke
5//! under Claude Desktop: Desktop launches MCP servers from an undefined
6//! cwd (often `/` on macOS), so the safety boundary collapsed to the whole
7//! filesystem.
8//!
9//! v0.1.1 takes the root from three sources, in this precedence order:
10//!
11//! 1. `--root <PATH>` CLI flag
12//! 2. `SHELL_MCP_ROOT` environment variable
13//! 3. The process's launch cwd (legacy behaviour, kept as a fallback for
14//!    direct shell invocations).
15//!
16//! Whichever source wins, the path must already be **absolute**, must
17//! exist, and must be a directory. We then canonicalize so symlinks are
18//! resolved up front (otherwise the lexical containment check in
19//! [`crate::safety::resolve_cwd`] would compare against an unresolved
20//! prefix and a request for the symlink target would falsely escape).
21
22use std::path::{Path, PathBuf};
23
24use thiserror::Error;
25
26#[derive(Debug, Error)]
27pub enum RootError {
28    #[error("launch root must be an absolute path; got `{path}` (set --root or SHELL_MCP_ROOT)")]
29    NotAbsolute { path: String },
30
31    #[error("launch root does not exist: `{path}`")]
32    DoesNotExist { path: String },
33
34    #[error("launch root is not a directory: `{path}`")]
35    NotDirectory { path: String },
36
37    #[error("could not canonicalize launch root `{path}`: {source}")]
38    Canonicalize {
39        path: String,
40        #[source]
41        source: std::io::Error,
42    },
43
44    #[error("could not read launch root `{path}`: {source}")]
45    Stat {
46        path: String,
47        #[source]
48        source: std::io::Error,
49    },
50}
51
52/// Where the resolved root came from. Surfaced in logs so the operator can
53/// tell which input was honoured.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum RootSource {
56    Flag,
57    Env,
58    LaunchCwd,
59}
60
61impl RootSource {
62    pub fn as_str(&self) -> &'static str {
63        match self {
64            RootSource::Flag => "--root flag",
65            RootSource::Env => "SHELL_MCP_ROOT env var",
66            RootSource::LaunchCwd => "launch cwd",
67        }
68    }
69}
70
71/// The chosen root plus the source it came from.
72#[derive(Debug, Clone)]
73pub struct ResolvedRoot {
74    pub path: PathBuf,
75    pub source: RootSource,
76}
77
78/// Pure resolution function so unit tests can drive every case without
79/// touching the process environment.
80///
81/// `cli` is the value of `--root`; `env` is the value of `SHELL_MCP_ROOT`
82/// (an empty string is treated as unset to match shell ergonomics);
83/// `fallback_cwd` is what the process believes its cwd to be.
84///
85/// The launch-cwd fallback is intentionally permissive — it accepts
86/// whatever the OS reports — because direct shell invocations
87/// (`cd ~/proj && shell-mcp`) should still work without ceremony. The
88/// strict absolute-path requirement only applies when the user explicitly
89/// supplied a flag or env value.
90pub fn resolve_root(
91    cli: Option<&Path>,
92    env: Option<&str>,
93    fallback_cwd: &Path,
94) -> Result<ResolvedRoot, RootError> {
95    let (raw, source) = if let Some(p) = cli {
96        (p.to_path_buf(), RootSource::Flag)
97    } else if let Some(s) = env.filter(|s| !s.is_empty()) {
98        (PathBuf::from(s), RootSource::Env)
99    } else {
100        (fallback_cwd.to_path_buf(), RootSource::LaunchCwd)
101    };
102
103    // Strict checks apply to user-supplied paths only.
104    if matches!(source, RootSource::Flag | RootSource::Env) && !raw.is_absolute() {
105        return Err(RootError::NotAbsolute {
106            path: raw.display().to_string(),
107        });
108    }
109
110    let metadata = match std::fs::metadata(&raw) {
111        Ok(m) => m,
112        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
113            return Err(RootError::DoesNotExist {
114                path: raw.display().to_string(),
115            });
116        }
117        Err(e) => {
118            return Err(RootError::Stat {
119                path: raw.display().to_string(),
120                source: e,
121            });
122        }
123    };
124    if !metadata.is_dir() {
125        return Err(RootError::NotDirectory {
126            path: raw.display().to_string(),
127        });
128    }
129
130    let path = raw.canonicalize().map_err(|e| RootError::Canonicalize {
131        path: raw.display().to_string(),
132        source: e,
133    })?;
134
135    Ok(ResolvedRoot { path, source })
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use std::fs;
142    use tempfile::tempdir;
143
144    #[test]
145    fn flag_overrides_env() {
146        let flag_dir = tempdir().unwrap();
147        let env_dir = tempdir().unwrap();
148        let cwd_dir = tempdir().unwrap();
149        let r = resolve_root(
150            Some(flag_dir.path()),
151            Some(env_dir.path().to_str().unwrap()),
152            cwd_dir.path(),
153        )
154        .unwrap();
155        assert_eq!(r.source, RootSource::Flag);
156        assert_eq!(r.path, flag_dir.path().canonicalize().unwrap());
157    }
158
159    #[test]
160    fn env_overrides_launch_cwd() {
161        let env_dir = tempdir().unwrap();
162        let cwd_dir = tempdir().unwrap();
163        let r = resolve_root(None, Some(env_dir.path().to_str().unwrap()), cwd_dir.path()).unwrap();
164        assert_eq!(r.source, RootSource::Env);
165        assert_eq!(r.path, env_dir.path().canonicalize().unwrap());
166    }
167
168    #[test]
169    fn empty_env_falls_through_to_launch_cwd() {
170        let cwd_dir = tempdir().unwrap();
171        let r = resolve_root(None, Some(""), cwd_dir.path()).unwrap();
172        assert_eq!(r.source, RootSource::LaunchCwd);
173    }
174
175    #[test]
176    fn launch_cwd_used_when_nothing_else_set() {
177        let cwd_dir = tempdir().unwrap();
178        let r = resolve_root(None, None, cwd_dir.path()).unwrap();
179        assert_eq!(r.source, RootSource::LaunchCwd);
180        assert_eq!(r.path, cwd_dir.path().canonicalize().unwrap());
181    }
182
183    #[test]
184    fn nonexistent_path_rejected() {
185        let parent = tempdir().unwrap();
186        let missing = parent.path().join("does-not-exist");
187        let cwd_dir = tempdir().unwrap();
188        let err = resolve_root(Some(&missing), None, cwd_dir.path()).unwrap_err();
189        assert!(matches!(err, RootError::DoesNotExist { .. }), "got {err:?}");
190    }
191
192    #[test]
193    fn file_not_directory_rejected() {
194        let dir = tempdir().unwrap();
195        let file = dir.path().join("a.txt");
196        fs::write(&file, b"hi").unwrap();
197        let cwd_dir = tempdir().unwrap();
198        let err = resolve_root(Some(&file), None, cwd_dir.path()).unwrap_err();
199        assert!(matches!(err, RootError::NotDirectory { .. }), "got {err:?}");
200    }
201
202    #[test]
203    fn relative_flag_path_rejected() {
204        let cwd_dir = tempdir().unwrap();
205        let rel = Path::new("relative/path");
206        let err = resolve_root(Some(rel), None, cwd_dir.path()).unwrap_err();
207        assert!(matches!(err, RootError::NotAbsolute { .. }), "got {err:?}");
208    }
209
210    #[test]
211    fn relative_env_path_rejected() {
212        let cwd_dir = tempdir().unwrap();
213        let err = resolve_root(None, Some("also/relative"), cwd_dir.path()).unwrap_err();
214        assert!(matches!(err, RootError::NotAbsolute { .. }), "got {err:?}");
215    }
216
217    #[cfg(unix)]
218    #[test]
219    fn symlinks_are_resolved() {
220        use std::os::unix::fs::symlink;
221        let real = tempdir().unwrap();
222        let link_parent = tempdir().unwrap();
223        let link = link_parent.path().join("alias");
224        symlink(real.path(), &link).unwrap();
225
226        let cwd_dir = tempdir().unwrap();
227        let r = resolve_root(Some(&link), None, cwd_dir.path()).unwrap();
228        assert_eq!(r.source, RootSource::Flag);
229        // The resolved path must point at the real directory, not the link.
230        assert_eq!(r.path, real.path().canonicalize().unwrap());
231        assert_ne!(r.path, link);
232    }
233}