Skip to main content

shell_mcp/
safety.rs

1//! Hard safety checks that the user's TOML configuration cannot override.
2//!
3//! Two layers live here:
4//!
5//! 1. Syntactic rejection of shell metacharacters (`;`, `&&`, `||`, `|`,
6//!    backticks, `$()`, `>`, `<`, `>>`). v0.1 takes the position that
7//!    composite shell pipelines must be expressed as scripts and the script
8//!    itself allowlisted.
9//! 2. A hard denylist of token patterns that no allowlist can re-enable
10//!    (`sudo`, `rm -rf /`, classic fork bombs).
11//!
12//! Working-directory containment lives here as well: every command must
13//! resolve to a path inside the launch root.
14//!
15//! These checks run *before* allowlist matching so that the user can never
16//! accidentally write a TOML rule that lets a dangerous command through.
17
18use std::path::{Component, Path, PathBuf};
19
20use thiserror::Error;
21
22/// Substrings that immediately disqualify a command in v0.1.
23///
24/// Order matters only for diagnostic messages — the first hit wins.
25const METACHARACTERS: &[&str] = &[
26    "&&", "||", ">>", // multi-char first so we report the most specific match
27    ";", "|", "`", "$(", ">", "<",
28];
29
30/// Token sequences that are always rejected, regardless of allowlist.
31///
32/// Each entry is a sequence of glob-free, exact-match tokens. The matcher
33/// looks for these sequences anywhere in the parsed command tokens.
34const HARD_DENY: &[&[&str]] = &[
35    &["sudo"],
36    &["doas"],
37    &["su"],
38    &["rm", "-rf", "/"],
39    &["rm", "-rf", "/*"],
40    &["rm", "-fr", "/"],
41    &["rm", "--recursive", "--force", "/"],
42    &[":(){", ":|:&", "};:"], // classic fork bomb tokenization
43    &["mkfs"],
44    &["mkfs.ext4"],
45    &["dd", "if=/dev/zero"],
46    &["dd", "if=/dev/random"],
47    &["chmod", "-R", "777", "/"],
48    &["chown", "-R", "root", "/"],
49];
50
51/// Why a command was refused.
52#[derive(Debug, Error)]
53pub enum Rejection {
54    #[error("command rejected: shell metacharacter `{token}` is not allowed in v0.1 (write a script and allowlist it instead)")]
55    Metacharacter { token: String },
56
57    #[error("command rejected by hard denylist (rule: `{rule}`); this rule cannot be overridden by .shell-mcp.toml")]
58    HardDeny { rule: String },
59
60    #[error(
61        "command rejected: requested working directory `{requested}` escapes launch root `{root}`"
62    )]
63    EscapesRoot { requested: String, root: String },
64
65    #[error("command rejected: empty command")]
66    Empty,
67
68    #[error("command rejected: could not parse command tokens ({reason})")]
69    ParseError { reason: String },
70}
71
72impl Rejection {
73    pub fn kind(&self) -> RejectionKind {
74        match self {
75            Rejection::Metacharacter { .. } => RejectionKind::Metacharacter,
76            Rejection::HardDeny { .. } => RejectionKind::HardDeny,
77            Rejection::EscapesRoot { .. } => RejectionKind::EscapesRoot,
78            Rejection::Empty => RejectionKind::Empty,
79            Rejection::ParseError { .. } => RejectionKind::ParseError,
80        }
81    }
82}
83
84/// Stable categorisation suitable for serialising into MCP tool responses.
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum RejectionKind {
87    Metacharacter,
88    HardDeny,
89    EscapesRoot,
90    Empty,
91    ParseError,
92}
93
94impl RejectionKind {
95    pub fn as_str(&self) -> &'static str {
96        match self {
97            RejectionKind::Metacharacter => "metacharacter",
98            RejectionKind::HardDeny => "hard_deny",
99            RejectionKind::EscapesRoot => "escapes_root",
100            RejectionKind::Empty => "empty",
101            RejectionKind::ParseError => "parse_error",
102        }
103    }
104}
105
106/// Reject any command containing the v0.1 metacharacter set.
107pub fn check_metacharacters(raw: &str) -> Result<(), Rejection> {
108    for token in METACHARACTERS {
109        if raw.contains(token) {
110            return Err(Rejection::Metacharacter {
111                token: (*token).to_string(),
112            });
113        }
114    }
115    Ok(())
116}
117
118/// Tokenize the command using POSIX shell quoting rules so that quoted
119/// arguments survive (`git commit -m "fix: thing"` becomes four tokens).
120///
121/// Metacharacter rejection runs first, so any pipeline syntax never reaches
122/// this function in normal operation.
123pub fn tokenize(raw: &str) -> Result<Vec<String>, Rejection> {
124    let trimmed = raw.trim();
125    if trimmed.is_empty() {
126        return Err(Rejection::Empty);
127    }
128    shlex::split(trimmed).ok_or_else(|| Rejection::ParseError {
129        reason: "unbalanced quotes".to_string(),
130    })
131}
132
133/// Walk the parsed tokens looking for any hard-denied subsequence.
134pub fn check_hard_denylist(tokens: &[String]) -> Result<(), Rejection> {
135    for rule in HARD_DENY {
136        if contains_subsequence(tokens, rule) {
137            return Err(Rejection::HardDeny {
138                rule: rule.join(" "),
139            });
140        }
141    }
142    Ok(())
143}
144
145/// True if `needle` appears as a contiguous subsequence of `haystack`.
146fn contains_subsequence(haystack: &[String], needle: &[&str]) -> bool {
147    if needle.is_empty() || needle.len() > haystack.len() {
148        return false;
149    }
150    haystack
151        .windows(needle.len())
152        .any(|window| window.iter().zip(needle).all(|(h, n)| h == n))
153}
154
155/// Resolve `requested` against `root`, ensuring the result stays inside `root`.
156///
157/// `requested` may be `None` (use the root itself), a relative path (joined to
158/// the root), or an absolute path (must already be inside the root). `..`
159/// components are normalised lexically before the containment check so that
160/// `subdir/../..` cannot escape.
161pub fn resolve_cwd(root: &Path, requested: Option<&str>) -> Result<PathBuf, Rejection> {
162    let root = normalize(root);
163    let candidate = match requested {
164        None | Some("") => root.clone(),
165        Some(p) => {
166            let p = Path::new(p);
167            if p.is_absolute() {
168                normalize(p)
169            } else {
170                normalize(&root.join(p))
171            }
172        }
173    };
174    if !candidate.starts_with(&root) {
175        return Err(Rejection::EscapesRoot {
176            requested: candidate.display().to_string(),
177            root: root.display().to_string(),
178        });
179    }
180    Ok(candidate)
181}
182
183/// Lexical path normalisation that collapses `.` and `..` without touching
184/// the filesystem. We deliberately avoid `canonicalize` because it requires
185/// the path to exist and resolves symlinks — neither is appropriate for the
186/// cwd containment check.
187fn normalize(path: &Path) -> PathBuf {
188    let mut out = PathBuf::new();
189    for comp in path.components() {
190        match comp {
191            Component::ParentDir => {
192                out.pop();
193            }
194            Component::CurDir => {}
195            other => out.push(other.as_os_str()),
196        }
197    }
198    out
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn metacharacters_are_rejected() {
207        for bad in [
208            "ls; rm -rf foo",
209            "ls && rm foo",
210            "ls || true",
211            "ls | grep foo",
212            "ls > out",
213            "ls < in",
214            "ls >> out",
215            "echo `whoami`",
216            "echo $(whoami)",
217        ] {
218            assert!(check_metacharacters(bad).is_err(), "should reject: {bad}");
219        }
220    }
221
222    #[test]
223    fn plain_commands_pass_metacharacter_check() {
224        for good in ["ls -la", "git status", "cargo build --release"] {
225            assert!(check_metacharacters(good).is_ok(), "should allow: {good}");
226        }
227    }
228
229    #[test]
230    fn sudo_is_always_denied() {
231        let tokens = tokenize("sudo ls").unwrap();
232        assert!(matches!(
233            check_hard_denylist(&tokens),
234            Err(Rejection::HardDeny { .. })
235        ));
236    }
237
238    #[test]
239    fn rm_rf_root_is_always_denied() {
240        let tokens = tokenize("rm -rf /").unwrap();
241        assert!(matches!(
242            check_hard_denylist(&tokens),
243            Err(Rejection::HardDeny { .. })
244        ));
245    }
246
247    #[test]
248    fn cwd_inside_root_is_accepted() {
249        let root = PathBuf::from("/tmp/launch");
250        assert_eq!(
251            resolve_cwd(&root, Some("sub/dir")).unwrap(),
252            PathBuf::from("/tmp/launch/sub/dir")
253        );
254        assert_eq!(resolve_cwd(&root, None).unwrap(), root);
255    }
256
257    #[test]
258    fn cwd_escaping_root_is_rejected() {
259        let root = PathBuf::from("/tmp/launch");
260        assert!(resolve_cwd(&root, Some("../other")).is_err());
261        assert!(resolve_cwd(&root, Some("sub/../../other")).is_err());
262        assert!(resolve_cwd(&root, Some("/etc")).is_err());
263    }
264}