1use std::path::{Component, Path, PathBuf};
19
20use thiserror::Error;
21
22const METACHARACTERS: &[&str] = &[
26 "&&", "||", ">>", ";", "|", "`", "$(", ">", "<",
28];
29
30const HARD_DENY: &[&[&str]] = &[
35 &["sudo"],
36 &["doas"],
37 &["su"],
38 &["rm", "-rf", "/"],
39 &["rm", "-rf", "/*"],
40 &["rm", "-fr", "/"],
41 &["rm", "--recursive", "--force", "/"],
42 &[":(){", ":|:&", "};:"], &["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#[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#[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
106pub 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
118pub 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
133pub 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
145fn 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
155pub 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
183fn 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}